SPB Git

spb/uqo-chat

Public

feat(auth): whitelist + Resend invitations replace the course access code

- professor adds student e-mails → each gets a 'choose your password' link (Resend batch, 14 d)
- /mot-de-passe?token= page (activation or reset), password strength meter, consent on first login
- 'Première connexion ou mot de passe oublié' → /auth/forgot (registered addresses only, 60 min link)
- students tab: invite on create, per-student resend, invite-all pending, activated/invited status
- access code removed (endpoints, UI, config); legacy /connexion?token= links still honoured
- services/mail.py: Resend HTTP API (single + batch) with SMTP fallback, HTML templates in UQO brand

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 6, 2026) parent 1b12758 Browse files

16 changed files +865 −216

modified .env.example +6 −2
@@ -31,8 +31,12 @@ ALLOWED_EMAIL_DOMAINS=uqo.ca
31 INVITED_EMAILS= 31 INVITED_EMAILS=
32 PROFESSOR_EMAILS=prof@uqo.ca 32 PROFESSOR_EMAILS=prof@uqo.ca
33 ADMIN_EMAILS= 33 ADMIN_EMAILS=
34−# Code d'accès du cours (connexion sans SMTP) — laisser vide pour imposer le lien magique 34+# Courriels (invitation « choisis ton mot de passe », mot de passe oublié) : Resend d'abord, SMTP en repli
35−ACCESS_CODE= 35+RESEND_API_KEY=
36+MAIL_FROM=UQO-Chat <no-reply@uqo-chat.app>
37+MAIL_REPLY_TO=
38+INVITE_TTL_DAYS=14
39+RESET_TTL_MINUTES=60
36 SMTP_HOST= 40 SMTP_HOST=
37 SMTP_PORT=587 41 SMTP_PORT=587
38 SMTP_USER= 42 SMTP_USER=
modified CLAUDE.md +3 −3
@@ -14,14 +14,14 @@ Voir `README.md` pour le démarrage et le déploiement.
14 7. Mobile-first (375 px) ; QA Playwright : `python3 /tmp/uqo-qa/qa.py` (captures 375/1440, détection d'overflow). 14 7. Mobile-first (375 px) ; QA Playwright : `python3 /tmp/uqo-qa/qa.py` (captures 375/1440, détection d'overflow).
15 15
16 ## État (v0.3, 2026-09-06) 16 ## État (v0.3, 2026-09-06)
17−- 13 outils : search_course_content, appraisal_calc, financial_calc, unit_convert, execute_python, make_chart, create_excel, inspect_excel, edit_excel, create_docx, analyze_file, web_search, generate_quiz. Auth : code du cours, mot de passe (PBKDF2), lien magique. Tableau de bord prof : Activité, Étudiants (création en lot, liens, mots de passe, code d'accès), Contenu, Réglages. Modèle par défaut `anthropic/claude-fable-5.1`, budget 2 000 $ US/mois. 17+- 13 outils : search_course_content, appraisal_calc, financial_calc, unit_convert, execute_python, make_chart, create_excel, inspect_excel, edit_excel, create_docx, analyze_file, web_search, generate_quiz. Auth (v0.5) : liste blanche gérée par le professeur → invitation Resend « choisis ton mot de passe » (`/mot-de-passe?token=`) → connexion courriel + mot de passe (PBKDF2) ; « première connexion / mot de passe oublié » par courriel ; plus de code d'accès. Tableau de bord prof : Activité, Étudiants (ajout en lot + invitations, relances, statut activé/invité, mots de passe manuels), Contenu, Réglages. Modèle par défaut `anthropic/claude-fable-5.1`, budget 2 000 $ US/mois.
18−- Backend FastAPI 3.12 : auth (lien magique **ou** code d'accès), conversations, chat SSE avec boucle agentique (8 itérations, outils en parallèle), 7 outils, RAG BM25 sur les sites de notes (`rag/ingest.py` lit le HTML généré `dist/<cours>/seance/NN`), quiz, fichiers (TTL), tableau de bord prof (analytics anonymisées, ingestion, réglages, annonces), coûts admin, purge/anonymisation horaire. 18+- Backend FastAPI 3.12 : auth (invitation/réinitialisation par courriel Resend + mot de passe ; `services/mail.py`, `services/invites.py`), conversations, chat SSE avec boucle agentique (8 itérations, outils en parallèle), 7 outils, RAG BM25 sur les sites de notes (`rag/ingest.py` lit le HTML généré `dist/<cours>/seance/NN`), quiz, fichiers (TTL), tableau de bord prof (analytics anonymisées, ingestion, réglages, annonces), coûts admin, purge/anonymisation horaire.
19 - Frontend React 18 + Vite + Tailwind : chat mobile/desktop, cartes d'outils, quiz interactif, panneau fichiers/sources, préférences, Loi 25 (consentement, suppression), PWA (précache ≈ 2 Mo). 19 - Frontend React 18 + Vite + Tailwind : chat mobile/desktop, cartes d'outils, quiz interactif, panneau fichiers/sources, préférences, Loi 25 (consentement, suppression), PWA (précache ≈ 2 Mo).
20 - Sandbox : `sandbox-runner/` (FastAPI) exécute `python -I -B` sous `sandbox-exec` (macOS) : réseau interdit, écriture confinée, rlimits, timeout, figures matplotlib capturées. 20 - Sandbox : `sandbox-runner/` (FastAPI) exécute `python -I -B` sous `sandbox-exec` (macOS) : réseau interdit, écriture confinée, rlimits, timeout, figures matplotlib capturées.
21 - Déploiement : `mld` (PM2 `uqo-chat-api` :8190 + `uqo-chat-sandbox` :8191 + `uqo-chat-ngrok` www.uqo-chat.app). Secrets dans `M1M32:~/dispatch/apps/uqo-chat.json`. 21 - Déploiement : `mld` (PM2 `uqo-chat-api` :8190 + `uqo-chat-sandbox` :8191 + `uqo-chat-ngrok` www.uqo-chat.app). Secrets dans `M1M32:~/dispatch/apps/uqo-chat.json`.
22 22
23 ## Écarts assumés 23 ## Écarts assumés
24−SQLite au lieu de Postgres/pgvector (Postgres accepté via `DATABASE_URL`) ; cache/limiteur mémoire au lieu de Redis ; fichiers sur disque au lieu de MinIO ; BM25 au lieu d'embeddings (OpenRouter n'en propose pas ; `EMBEDDINGS_BASE_URL` optionnel) ; `create_all` au lieu d'Alembic ; code d'accès en plus du lien magique (pas de SMTP). `k8s/` + `docker-compose.yml` décrivent la cible complète de la spec. 24+SQLite au lieu de Postgres/pgvector (Postgres accepté via `DATABASE_URL`) ; cache/limiteur mémoire au lieu de Redis ; fichiers sur disque au lieu de MinIO ; BM25 au lieu d'embeddings (OpenRouter n'en propose pas ; `EMBEDDINGS_BASE_URL` optionnel) ; `create_all` au lieu d'Alembic ; Resend (API HTTP) au lieu d'un SMTP, mot de passe au lieu du lien magique à chaque connexion. `k8s/` + `docker-compose.yml` décrivent la cible complète de la spec.
25 25
26 ## À faire (roadmap spec) 26 ## À faire (roadmap spec)
27 Export PDF de conversation ; file d'attente hors-ligne (Background Sync) ; évaluation pédagogique automatique en CI (40 questions/cours, juge `MODEL_FAST`) ; tests adverses sandbox complets ; Alembic ; migration éventuelle Postgres/pgvector ; autorisation du logo officiel UQO (`VITE_USE_OFFICIAL_LOGO`). 27 Export PDF de conversation ; file d'attente hors-ligne (Background Sync) ; évaluation pédagogique automatique en CI (40 questions/cours, juge `MODEL_FAST`) ; tests adverses sandbox complets ; Alembic ; migration éventuelle Postgres/pgvector ; autorisation du logo officiel UQO (`VITE_USE_OFFICIAL_LOGO`).
modified README.md +9 −4
@@ -26,7 +26,7 @@ production actuelle tourne sur un nœud Mac du cluster via `mld` (voir `deploy/`
26 ## Démarrage local 26 ## Démarrage local
27 27
28 ```bash 28 ```bash
29−cp .env.example .env # OPENROUTER_API_KEY, FIRECRAWL_API_KEY, ACCESS_CODE… 29+cp .env.example .env # OPENROUTER_API_KEY, FIRECRAWL_API_KEY, RESEND_API_KEY…
30 make setup # venvs uv (backend, sandbox) + npm install 30 make setup # venvs uv (backend, sandbox) + npm install
31 make sandbox & # :8191 31 make sandbox & # :8191
32 make api & # :8190 (sert frontend/dist s'il existe) 32 make api & # :8190 (sert frontend/dist s'il existe)
@@ -35,8 +35,13 @@ make ingest COURSE=imm1033 SITE=~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dis
35 make test && make lint 35 make test && make lint
36 ``` 36 ```
37 37
38−Connexion locale : courriel autorisé (`ALLOWED_EMAIL_DOMAINS` / `INVITED_EMAILS`) + `ACCESS_CODE`, 38+Connexion : le professeur inscrit les courriels des étudiant·es (onglet **Étudiants**) ; chacun·e reçoit
39−ou lien magique si `SMTP_HOST` est configuré (en dev le lien est renvoyé dans la réponse). 39+un courriel Resend « Bienvenue sur UQO-Chat » avec un lien `/mot-de-passe?token=…` (valide
40+`INVITE_TTL_DAYS`) pour choisir son mot de passe, puis se connecte avec courriel + mot de passe.
41+« Première connexion ou mot de passe oublié » renvoie un lien (`RESET_TTL_MINUTES`). Seules les adresses
42+inscrites (ou listées dans `PROFESSOR_EMAILS` / `ADMIN_EMAILS` / `INVITED_EMAILS`) peuvent demander un lien.
43+Sans `RESEND_API_KEY` (ni SMTP), les liens sont affichés dans le tableau de bord pour être copiés ; en dev,
44+`/auth/forgot` renvoie `dev_link`.
40 45
41 ## Déploiement 46 ## Déploiement
42 47
@@ -63,7 +68,7 @@ pm2 restart uqo-chat-api
63 |---|---|---| 68 |---|---|---|
64 | PostgreSQL + pgvector, Redis, MinIO, arq | SQLite (SQLAlchemy async), cache/limiteur en mémoire, fichiers sur disque, tâches asyncio | Déploiement mono-nœud sans dépendances ; `DATABASE_URL` accepte déjà Postgres, `k8s/` décrit la cible complète | 69 | PostgreSQL + pgvector, Redis, MinIO, arq | SQLite (SQLAlchemy async), cache/limiteur en mémoire, fichiers sur disque, tâches asyncio | Déploiement mono-nœud sans dépendances ; `DATABASE_URL` accepte déjà Postgres, `k8s/` décrit la cible complète |
65 | Embeddings `openai/text-embedding-3-large` via OpenRouter | BM25 (index en mémoire) + embeddings optionnels via un endpoint OpenAI-compatible (`EMBEDDINGS_BASE_URL`) | OpenRouter n'expose aucun modèle d'embeddings (vérifié 2026-09-05) | 70 | Embeddings `openai/text-embedding-3-large` via OpenRouter | BM25 (index en mémoire) + embeddings optionnels via un endpoint OpenAI-compatible (`EMBEDDINGS_BASE_URL`) | OpenRouter n'expose aucun modèle d'embeddings (vérifié 2026-09-05) |
66−| Lien magique SMTP obligatoire | Lien magique **ou** code d'accès du cours (`ACCESS_CODE`) | Aucun SMTP disponible ; le code est distribué par le professeur | 71+| Lien magique SMTP à chaque connexion | Invitation Resend → mot de passe choisi par l'étudiant·e (PBKDF2), « mot de passe oublié » par courriel ; liste blanche gérée par le professeur | Une seule étape par courriel, puis connexion classique ; Resend (`RESEND_API_KEY`) plutôt qu'un SMTP UQO |
67 | Pods sandbox gVisor / Job k8s | Service `sandbox-runner` séparé, `sandbox-exec` macOS (réseau interdit, écriture confinée) + rlimits + timeout | Équivalent local ; `sandbox-runner/Dockerfile` + NetworkPolicy prêts pour k8s | 72 | Pods sandbox gVisor / Job k8s | Service `sandbox-runner` séparé, `sandbox-exec` macOS (réseau interdit, écriture confinée) + rlimits + timeout | Équivalent local ; `sandbox-runner/Dockerfile` + NetworkPolicy prêts pour k8s |
68 | Alembic | `create_all` au démarrage | Une migration initiale sera ajoutée au premier changement de schéma | 73 | Alembic | `create_all` au démarrage | Une migration initiale sera ajoutée au premier changement de schéma |
69 | Export PDF de conversation, mode hors-ligne complet | Non faits (PWA : app shell + cache lecture) | Phase 2/3 | 74 | Export PDF de conversation, mode hors-ligne complet | Non faits (PWA : app shell + cache lecture) | Phase 2/3 |
modified backend/app/api/v1/auth.py +96 −41
@@ -1,8 +1,12 @@
1−"""Auth: magic link (SMTP) or access code fallback; JWT cookie + bearer; /me.""" 1+"""Auth: e-mail + password, activation / reset links by e-mail (Resend); JWT cookie + bearer; /me.
2+
3+Flow: the professor registers the students' addresses → each receives a "choose your password"
4+link → the password is then used on every sign-in. "Première connexion ou mot de passe oublié"
5+re-sends such a link to any registered address.
6+"""
2 7
3 from __future__ import annotations 8 from __future__ import annotations
4 9
5−import hmac
6 from datetime import timedelta 10 from datetime import timedelta
7 11
8 from fastapi import APIRouter, Depends, HTTPException, Request, Response, status 12 from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
@@ -19,14 +23,11 @@ from app.core.security import (
19 get_current_user, 23 get_current_user,
20 verify_password, 24 verify_password,
21 ) 25 )
22−from app.services import mail, users 26+from app.services import invites, users
23 27
24 router = APIRouter(tags=["auth"]) 28 router = APIRouter(tags=["auth"])
25 29
26− 30+MIN_PASSWORD = 8
27−class MagicLinkReq(BaseModel):
28− email: EmailStr
29− access_code: str | None = None
30 31
31 32
32 class VerifyReq(BaseModel): 33 class VerifyReq(BaseModel):
@@ -38,6 +39,15 @@ class PasswordLoginReq(BaseModel):
38 password: str 39 password: str
39 40
40 41
42+class ForgotReq(BaseModel):
43+ email: EmailStr
44+
45+
46+class SetPasswordReq(BaseModel):
47+ token: str
48+ password: str
49+
50+
41 class ChangePasswordReq(BaseModel): 51 class ChangePasswordReq(BaseModel):
42 current_password: str | None = None 52 current_password: str | None = None
43 new_password: str 53 new_password: str
@@ -70,37 +80,15 @@ def _issue(user_id: str, role: str, settings: Settings, resp: Response) -> dict:
70 return {"token": token, "role": role} 80 return {"token": token, "role": role}
71 81
72 82
73−@router.post("/auth/magic-link") 83+def _check_password_strength(password: str) -> None:
74−async def magic_link(req: MagicLinkReq, request: Request, response: Response, 84+ if len(password) < MIN_PASSWORD:
75− settings: Settings = Depends(get_settings)) -> dict: 85+ raise HTTPException(status.HTTP_400_BAD_REQUEST,
76− ip = request.client.host if request.client else "?" 86+ detail=f"Mot de passe trop court ({MIN_PASSWORD} caractères minimum).")
77− limiter.check(f"login:{ip}", 20, 3600, "Trop de tentatives. Réessaie plus tard.") 87+ if len(password) > 128:
78− email = req.email.lower() 88+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop long (128 maximum).")
79− if not email_allowed(email, settings) and not await users.email_registered(email):
80− raise HTTPException(status.HTTP_403_FORBIDDEN,
81− detail="Adresse non admise. Utilise ton courriel @uqo.ca ou demande au "
82− "professeur de créer ton compte.")
83− # Access-code path: no SMTP needed (course code handed out by the professor).
84− if req.access_code is not None:
85− code = await users.effective_access_code(settings)
86− if not code or not hmac.compare_digest(req.access_code.strip(), code):
87− raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Code d'accès invalide.")
88− user = await users.get_or_create_user(email, settings)
89− out = _issue(user.id, user.role, settings, response)
90− return {"mode": "access_code", **out, "user": _user_dict(user)}
91− token = await users.create_magic_link(email)
92− link = f"{settings.APP_URL}/connexion?token={token}"
93− sent = await mail.send_magic_link(settings, email, link)
94− out: dict = {"mode": "magic_link", "sent": sent}
95− if not sent:
96− if settings.is_dev:
97− out["dev_link"] = link # local development only
98− else:
99− out["hint"] = ("Envoi de courriel non configuré : utilise le code d'accès du cours."
100− if settings.ACCESS_CODE else "Envoi de courriel non configuré.")
101− return out
102 89
103 90
91+# ------------------------------------------------------------------ sign-in
104 @router.post("/auth/login") 92 @router.post("/auth/login")
105 async def password_login(req: PasswordLoginReq, request: Request, response: Response, 93 async def password_login(req: PasswordLoginReq, request: Request, response: Response,
106 settings: Settings = Depends(get_settings)) -> dict: 94 settings: Settings = Depends(get_settings)) -> dict:
@@ -109,15 +97,82 @@ async def password_login(req: PasswordLoginReq, request: Request, response: Resp
109 limiter.check(f"pwd:{req.email.lower()}", 10, 900, "Trop de tentatives pour ce compte. Patiente 15 minutes.") 97 limiter.check(f"pwd:{req.email.lower()}", 10, 900, "Trop de tentatives pour ce compte. Patiente 15 minutes.")
110 user = await users.authenticate(req.email, req.password) 98 user = await users.authenticate(req.email, req.password)
111 if not user: 99 if not user:
100+ existing = await users.get_user_by_email(req.email)
101+ if existing and not existing.password_hash:
102+ raise HTTPException(status.HTTP_401_UNAUTHORIZED,
103+ detail="Ce compte n'a pas encore de mot de passe. Clique sur "
104+ "« Première connexion ou mot de passe oublié » pour en choisir un.")
112 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Courriel ou mot de passe invalide.") 105 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Courriel ou mot de passe invalide.")
113 out = _issue(user.id, user.role, settings, response) 106 out = _issue(user.id, user.role, settings, response)
114 return {"mode": "password", **out, "user": _user_dict(user)} 107 return {"mode": "password", **out, "user": _user_dict(user)}
115 108
116 109
110+@router.post("/auth/forgot")
111+async def forgot_password(req: ForgotReq, request: Request,
112+ settings: Settings = Depends(get_settings)) -> dict:
113+ """First login or forgotten password: e-mail a single-use 'choose your password' link."""
114+ ip = request.client.host if request.client else "?"
115+ limiter.check(f"forgot:{ip}", 20, 3600, "Trop de demandes. Réessaie plus tard.")
116+ email = req.email.lower().strip()
117+ limiter.check(f"forgot:{email}", 5, 3600, "Trop de demandes pour cette adresse. Réessaie dans une heure.")
118+ user = await users.get_user_by_email(email)
119+ if user is None:
120+ # Addresses listed in the environment (professor/admin/invited) may self-register.
121+ if email in settings.invited_emails | settings.professor_emails | settings.admin_emails:
122+ user = await users.get_or_create_user(email, settings)
123+ else:
124+ hint = ("Cette adresse n'est pas inscrite au cours. Vérifie l'orthographe ou demande au "
125+ "professeur de t'ajouter à la liste des étudiants.")
126+ if not email_allowed(email, settings):
127+ hint = "Adresse non admise. Utilise ton courriel @uqo.ca inscrit par le professeur."
128+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=hint)
129+ sent, link = await invites.send_password_link(user, settings)
130+ out: dict = {"sent": sent, "first_time": not user.password_hash,
131+ "ttl_minutes": settings.RESET_TTL_MINUTES}
132+ if not sent:
133+ if settings.is_dev:
134+ out["dev_link"] = link # local development only
135+ else:
136+ out["hint"] = "Envoi de courriel indisponible pour le moment. Préviens le professeur."
137+ return out
138+
139+
140+@router.get("/auth/password-token")
141+async def password_token_info(token: str) -> dict:
142+ """Validate an activation/reset token without consuming it (drives the /mot-de-passe page)."""
143+ link = await users.peek_magic_link(token)
144+ if not link or (link.purpose or "login") not in {"invite", "reset"}:
145+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré.")
146+ user = await users.get_user_by_email(link.email)
147+ return {"email": link.email, "purpose": link.purpose,
148+ "first_time": not (user and user.password_hash),
149+ "display_name": user.display_name if user else None,
150+ "expires_at": link.expires_at.isoformat()}
151+
152+
153+@router.post("/auth/set-password")
154+async def set_password_with_token(req: SetPasswordReq, response: Response,
155+ settings: Settings = Depends(get_settings)) -> dict:
156+ """Consume an invite/reset token, store the password and open the session."""
157+ _check_password_strength(req.password)
158+ email = await users.consume_magic_link(req.token, purposes={"invite", "reset"})
159+ if not email:
160+ raise HTTPException(status.HTTP_400_BAD_REQUEST,
161+ detail="Lien invalide ou expiré. Demande un nouveau lien depuis la page de connexion.")
162+ user = await users.get_user_by_email(email) or await users.get_or_create_user(email, settings)
163+ await users.set_password(user.id, req.password)
164+ await users.invalidate_links(email, purposes={"invite", "reset"})
165+ user = await users.get_user(user.id)
166+ assert user is not None
167+ out = _issue(user.id, user.role, settings, response)
168+ return {"mode": "password_set", **out, "user": _user_dict(user)}
169+
170+
117 @router.post("/auth/verify") 171 @router.post("/auth/verify")
118 async def verify(req: VerifyReq, response: Response, 172 async def verify(req: VerifyReq, response: Response,
119 settings: Settings = Depends(get_settings)) -> dict: 173 settings: Settings = Depends(get_settings)) -> dict:
120− email = await users.consume_magic_link(req.token) 174+ """Legacy direct sign-in link (purpose 'login'), still honoured for links already sent."""
175+ email = await users.consume_magic_link(req.token, purposes={"login"})
121 if not email: 176 if not email:
122 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré.") 177 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré.")
123 user = await users.get_or_create_user(email, settings) 178 user = await users.get_or_create_user(email, settings)
@@ -147,8 +202,8 @@ async def logout(response: Response) -> dict:
147 202
148 @router.get("/auth/config") 203 @router.get("/auth/config")
149 async def auth_config(settings: Settings = Depends(get_settings)) -> dict: 204 async def auth_config(settings: Settings = Depends(get_settings)) -> dict:
150− return {"smtp": settings.smtp_enabled, "password": True, 205+ return {"password": True, "mail": settings.mail_enabled,
151− "access_code": bool(await users.effective_access_code(settings)), 206+ "min_password": MIN_PASSWORD,
152 "domains": sorted(settings.allowed_domains), "courses": settings.courses, 207 "domains": sorted(settings.allowed_domains), "courses": settings.courses,
153 "term": settings.TERM_LABEL} 208 "term": settings.TERM_LABEL}
154 209
@@ -159,6 +214,7 @@ def _user_dict(u) -> dict: # noqa: ANN001
159 "consent_at": u.consent_at.isoformat() if u.consent_at else None} 214 "consent_at": u.consent_at.isoformat() if u.consent_at else None}
160 215
161 216
217+# ------------------------------------------------------------------ me
162 @router.get("/me") 218 @router.get("/me")
163 async def me(auth: AuthUser = Depends(get_current_user)) -> dict: 219 async def me(auth: AuthUser = Depends(get_current_user)) -> dict:
164 user = await users.get_user(auth.id) 220 user = await users.get_user(auth.id)
@@ -181,8 +237,7 @@ async def change_password(req: ChangePasswordReq, auth: AuthUser = Depends(get_c
181 user = await users.get_user(auth.id) 237 user = await users.get_user(auth.id)
182 if not user: 238 if not user:
183 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.") 239 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")
184− if len(req.new_password) < 8: 240+ _check_password_strength(req.new_password)
185− raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop court (8 caractères minimum).")
186 if user.password_hash and not verify_password(req.current_password or "", user.password_hash): 241 if user.password_hash and not verify_password(req.current_password or "", user.password_hash):
187 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe actuel incorrect.") 242 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe actuel incorrect.")
188 await users.set_password(auth.id, req.new_password) 243 await users.set_password(auth.id, req.new_password)
modified backend/app/api/v1/professor.py +36 −27
@@ -18,7 +18,7 @@ from app.db import SessionLocal
18 from app.models import CourseChunk, CourseDocument 18 from app.models import CourseChunk, CourseDocument
19 from app.rag import retriever 19 from app.rag import retriever
20 from app.rag.ingest import ingest_file 20 from app.rag.ingest import ingest_file
21−from app.services import analytics, costs, users 21+from app.services import analytics, costs, invites, users
22 from app.services import courses as course_service 22 from app.services import courses as course_service
23 from app.tools.all import registry 23 from app.tools.all import registry
24 24
@@ -167,19 +167,23 @@ async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) ->
167 @router.get("/students") 167 @router.get("/students")
168 async def list_students(_: AuthUser = Depends(require_professor), 168 async def list_students(_: AuthUser = Depends(require_professor),
169 settings: Settings = Depends(get_settings)) -> dict: 169 settings: Settings = Depends(get_settings)) -> dict:
170− return {"users": await users.list_users(), 170+ rows = await users.list_users()
171− "access_code": await users.effective_access_code(settings), 171+ return {"users": rows, "mail": settings.mail_enabled,
172− "smtp": settings.smtp_enabled} 172+ "mail_from": settings.MAIL_FROM if settings.resend_enabled else settings.SMTP_FROM,
173+ "invite_ttl_days": settings.INVITE_TTL_DAYS,
174+ "pending": sum(1 for u in rows if not u["has_password"])}
173 175
174 176
175 class CreateStudentsReq(BaseModel): 177 class CreateStudentsReq(BaseModel):
176 emails: list[str] | str 178 emails: list[str] | str
177 role: str = "student" 179 role: str = "student"
180+ send_invitations: bool = True
178 181
179 182
180 @router.post("/students", status_code=201) 183 @router.post("/students", status_code=201)
181 async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_professor), 184 async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_professor),
182 settings: Settings = Depends(get_settings)) -> dict: 185 settings: Settings = Depends(get_settings)) -> dict:
186+ """Register addresses; each new account receives a 'choose your password' e-mail."""
183 raw = req.emails if isinstance(req.emails, list) else re.split(r"[\n;,]+", req.emails) 187 raw = req.emails if isinstance(req.emails, list) else re.split(r"[\n;,]+", req.emails)
184 names: dict[str, str] = {} 188 names: dict[str, str] = {}
185 emails: list[str] = [] 189 emails: list[str] = []
@@ -195,7 +199,30 @@ async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_
195 emails.extend(t for t in re.split(r"\s+", item) if t) 199 emails.extend(t for t in re.split(r"\s+", item) if t)
196 if req.role not in {"student", "professor"}: 200 if req.role not in {"student", "professor"}:
197 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.") 201 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")
198− return await users.create_users(emails[:500], settings, req.role, names) 202+ result = await users.create_users(emails[:500], settings, req.role, names)
203+ created_ids = result.pop("created_ids", [])
204+ result["invited"], result["invite_failed"] = [], []
205+ if req.send_invitations and created_ids:
206+ targets = [u for u in [await users.get_user(i) for i in created_ids] if u]
207+ outcome = await invites.invite_users(targets, settings)
208+ result["invited"], result["invite_failed"] = outcome["sent"], outcome["failed"]
209+ result["mail"] = settings.mail_enabled
210+ return result
211+
212+
213+class InviteAllReq(BaseModel):
214+ only_never_invited: bool = True
215+
216+
217+@router.post("/students/invite-all")
218+async def invite_all(req: InviteAllReq, _: AuthUser = Depends(require_professor),
219+ settings: Settings = Depends(get_settings)) -> dict:
220+ """(Re)send the activation e-mail to every account that has no password yet."""
221+ if not settings.mail_enabled:
222+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Envoi de courriel non configuré (RESEND_API_KEY).")
223+ targets = await users.users_without_password(only_never_invited=req.only_never_invited)
224+ outcome = await invites.invite_users(targets, settings)
225+ return {"sent": outcome["sent"], "failed": outcome["failed"], "total": len(targets)}
199 226
200 227
201 class RoleReq(BaseModel): 228 class RoleReq(BaseModel):
@@ -229,31 +256,13 @@ async def delete_student(user_id: str, auth: AuthUser = Depends(require_professo
229 @router.post("/students/{user_id}/invite") 256 @router.post("/students/{user_id}/invite")
230 async def invite_student(user_id: str, _: AuthUser = Depends(require_professor), 257 async def invite_student(user_id: str, _: AuthUser = Depends(require_professor),
231 settings: Settings = Depends(get_settings)) -> dict: 258 settings: Settings = Depends(get_settings)) -> dict:
232− """Personal sign-in link (7 days). Sent by e-mail when SMTP exists, otherwise returned to copy.""" 259+ """(Re)send the 'choose your password' e-mail; the link is also returned so it can be copied."""
233 u = await users.get_user(user_id) 260 u = await users.get_user(user_id)
234 if not u: 261 if not u:
235 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.") 262 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")
236− token = await users.create_magic_link(u.email, ttl_minutes=7 * 24 * 60) 263+ outcome = await invites.invite_users([u], settings)
237− link = f"{settings.APP_URL}/connexion?token={token}" 264+ return {"link": outcome["links"][u.id], "sent": u.email in outcome["sent"],
238− sent = False 265+ "expires_days": settings.INVITE_TTL_DAYS}
239− if settings.smtp_enabled:
240− from app.services import mail
241−
242− sent = await mail.send_magic_link(settings, u.email, link)
243− return {"link": link, "sent": sent, "expires_days": 7}
244−
245−
246−class AccessCodeReq(BaseModel):
247− access_code: str
248−
249−
250−@router.put("/access-code")
251−async def set_access_code(req: AccessCodeReq, _: AuthUser = Depends(require_professor)) -> dict:
252− code = req.access_code.strip()
253− if len(code) < 6:
254− raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Code trop court (6 caractères minimum).")
255− await users.set_setting("access_code", code)
256− return {"ok": True, "access_code": code}
257 266
258 267
259 class SetPasswordReq(BaseModel): 268 class SetPasswordReq(BaseModel):
modified backend/app/core/config.py +15 −1
@@ -71,7 +71,13 @@ class Settings(BaseSettings):
71 INVITED_EMAILS: str = "" 71 INVITED_EMAILS: str = ""
72 PROFESSOR_EMAILS: str = "" 72 PROFESSOR_EMAILS: str = ""
73 ADMIN_EMAILS: str = "" 73 ADMIN_EMAILS: str = ""
74− ACCESS_CODE: str = "" # course access code: lets users log in without SMTP 74+ INVITE_TTL_DAYS: int = 14 # "choose your password" link sent to newly added students
75+ RESET_TTL_MINUTES: int = 60 # "forgot my password" link
76+ # --- Mail (Resend first; SMTP kept as fallback) ---
77+ RESEND_API_KEY: SecretStr = SecretStr("")
78+ RESEND_BASE_URL: str = "https://api.resend.com"
79+ MAIL_FROM: str = "UQO-Chat <no-reply@uqo-chat.app>"
80+ MAIL_REPLY_TO: str = ""
75 SMTP_HOST: str = "" 81 SMTP_HOST: str = ""
76 SMTP_PORT: int = 587 82 SMTP_PORT: int = 587
77 SMTP_USER: str = "" 83 SMTP_USER: str = ""
@@ -133,6 +139,14 @@ class Settings(BaseSettings):
133 def smtp_enabled(self) -> bool: 139 def smtp_enabled(self) -> bool:
134 return bool(self.SMTP_HOST) 140 return bool(self.SMTP_HOST)
135 141
142+ @property
143+ def resend_enabled(self) -> bool:
144+ return bool(self.RESEND_API_KEY.get_secret_value())
145+
146+ @property
147+ def mail_enabled(self) -> bool:
148+ return self.resend_enabled or self.smtp_enabled
149+
136 @property 150 @property
137 def is_dev(self) -> bool: 151 def is_dev(self) -> bool:
138 return self.APP_ENV != "production" 152 return self.APP_ENV != "production"
modified backend/app/models/__init__.py +4 −0
@@ -44,14 +44,18 @@ class User(Base):
44 created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) 44 created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
45 last_seen_at: Mapped[datetime | None] = mapped_column(DateTime) 45 last_seen_at: Mapped[datetime | None] = mapped_column(DateTime)
46 deleted_at: Mapped[datetime | None] = mapped_column(DateTime) 46 deleted_at: Mapped[datetime | None] = mapped_column(DateTime)
47+ invited_at: Mapped[datetime | None] = mapped_column(DateTime) # last "choose password" e-mail
47 48
48 49
49 class MagicLink(Base): 50 class MagicLink(Base):
51+ """Single-use e-mailed tokens: login link, invitation (choose password), password reset."""
52+
50 __tablename__ = "magic_links" 53 __tablename__ = "magic_links"
51 token: Mapped[str] = mapped_column(String(64), primary_key=True) 54 token: Mapped[str] = mapped_column(String(64), primary_key=True)
52 email: Mapped[str] = mapped_column(String(255), index=True) 55 email: Mapped[str] = mapped_column(String(255), index=True)
53 expires_at: Mapped[datetime] = mapped_column(DateTime) 56 expires_at: Mapped[datetime] = mapped_column(DateTime)
54 used_at: Mapped[datetime | None] = mapped_column(DateTime) 57 used_at: Mapped[datetime | None] = mapped_column(DateTime)
58+ purpose: Mapped[str | None] = mapped_column(String(16)) # None/"login" | "invite" | "reset"
55 59
56 60
57 class Course(Base): 61 class Course(Base):
added backend/app/services/invites.py +47 −0
@@ -0,0 +1,47 @@
1+"""Account activation and password recovery by e-mail.
2+
3+- Invitation: the professor adds an address → a "choose your password" link (INVITE_TTL_DAYS).
4+- Forgot / first login: the student asks for a link from the sign-in page (RESET_TTL_MINUTES).
5+Both land on /mot-de-passe?token=… where the password is chosen and the session opened.
6+"""
7+
8+from __future__ import annotations
9+
10+from app.core.config import Settings
11+from app.models import User
12+from app.services import mail, users
13+
14+
15+def password_link(settings: Settings, token: str) -> str:
16+ return f"{settings.APP_URL}/mot-de-passe?token={token}"
17+
18+
19+async def invite_users(targets: list[User], settings: Settings) -> dict:
20+ """Create one invitation token per user, e-mail them (batch) and stamp invited_at."""
21+ if not targets:
22+ return {"sent": [], "failed": [], "links": {}}
23+ mails: list[mail.Mail] = []
24+ links: dict[str, str] = {}
25+ for u in targets:
26+ token = await users.create_magic_link(u.email, ttl_minutes=settings.INVITE_TTL_DAYS * 24 * 60,
27+ purpose="invite")
28+ link = password_link(settings, token)
29+ links[u.id] = link
30+ mails.append(mail.invitation_mail(u.email, link, u.display_name, settings.INVITE_TTL_DAYS,
31+ settings.TERM_LABEL))
32+ outcome = await mail.send_batch(settings, mails) if settings.mail_enabled else {}
33+ sent = [u for u in targets if outcome.get(u.email)]
34+ failed = [u for u in targets if not outcome.get(u.email)]
35+ await users.mark_invited([u.id for u in sent])
36+ return {"sent": [u.email for u in sent], "failed": [u.email for u in failed], "links": links}
37+
38+
39+async def send_password_link(user: User, settings: Settings) -> tuple[bool, str]:
40+ """Forgot-password / first-login link. Returns (sent, link)."""
41+ first_time = not user.password_hash
42+ token = await users.create_magic_link(user.email, ttl_minutes=settings.RESET_TTL_MINUTES,
43+ purpose="reset")
44+ link = password_link(settings, token)
45+ sent = await mail.send(settings, mail.reset_mail(user.email, link, settings.RESET_TTL_MINUTES,
46+ first_time))
47+ return sent, link
modified backend/app/services/mail.py +186 −12
@@ -1,32 +1,206 @@
1−"""Magic-link e-mail (SMTP). In dev or without SMTP the link is logged instead.""" 1+"""Transactional e-mail: Resend (HTTP API) first, SMTP fallback, otherwise logged only.
2+
3+Three messages: invitation (choose a password), password reset, legacy login link.
4+Batches use Resend's /emails/batch (≤100 messages per call) so a whole cohort of students
5+is invited with a handful of requests.
6+"""
2 7
3 from __future__ import annotations 8 from __future__ import annotations
4 9
10+import html
11+from dataclasses import dataclass
5 from email.message import EmailMessage 12 from email.message import EmailMessage
6 13
7−import aiosmtplib 14+import httpx
8 15
9 from app.core.config import Settings 16 from app.core.config import Settings
10 from app.core.logging import get_logger 17 from app.core.logging import get_logger
11 18
12 log = get_logger("mail") 19 log = get_logger("mail")
13 20
21+BRAND = "#0F6180"
22+BRAND_DARK = "#0B4A62"
23+GREEN = "#78B928"
24+
25+
26+@dataclass(slots=True)
27+class Mail:
28+ to: str
29+ subject: str
30+ text: str
31+ html: str
32+
33+
34+# ------------------------------------------------------------------ templates
35+def _layout(title: str, intro_html: str, cta_label: str, link: str, footer_html: str) -> str:
36+ return f"""<!doctype html>
37+<html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
38+<title>{html.escape(title)}</title></head>
39+<body style="margin:0;padding:0;background:#f3f6f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1c2b36;">
40+<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f3f6f9;padding:24px 12px;">
41+<tr><td align="center">
42+<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:520px;background:#ffffff;border-radius:20px;overflow:hidden;box-shadow:0 8px 30px rgba(15,97,128,.12);">
43+<tr><td style="background:linear-gradient(135deg,{BRAND} 0%,{BRAND_DARK} 100%);background-color:{BRAND};padding:28px 28px 22px;color:#ffffff;">
44+ <div style="font-size:22px;font-weight:800;letter-spacing:.2px;">UQO-Chat</div>
45+ <div style="font-size:13px;opacity:.85;margin-top:4px;">Tuteur IA · IMM1003 · IMM1033</div>
46+</td></tr>
47+<tr><td style="padding:28px 28px 8px;">
48+ <h1 style="margin:0 0 12px;font-size:20px;line-height:1.3;color:{BRAND_DARK};">{html.escape(title)}</h1>
49+ <div style="font-size:15px;line-height:1.55;">{intro_html}</div>
50+</td></tr>
51+<tr><td align="center" style="padding:16px 28px 8px;">
52+ <a href="{html.escape(link)}" style="display:inline-block;background:{BRAND};color:#ffffff;text-decoration:none;font-weight:700;font-size:15px;padding:14px 26px;border-radius:12px;">{html.escape(cta_label)}</a>
53+</td></tr>
54+<tr><td style="padding:8px 28px 24px;font-size:13px;line-height:1.5;color:#5b6b78;">
55+ <p style="margin:12px 0 0;">Si le bouton ne fonctionne pas, copie ce lien dans ton navigateur :<br>
56+ <a href="{html.escape(link)}" style="color:{BRAND};word-break:break-all;">{html.escape(link)}</a></p>
57+ {footer_html}
58+</td></tr>
59+<tr><td style="background:#f7f9fb;padding:14px 28px;font-size:12px;color:#7a8893;border-top:1px solid #e6ebf0;">
60+ Outil pédagogique de l'UQO. Ne remplace pas un évaluateur agréé (É.A.). Courriel automatique : ne pas répondre.
61+</td></tr>
62+</table>
63+</td></tr></table>
64+</body></html>"""
65+
66+
67+def invitation_mail(to: str, link: str, display_name: str | None, ttl_days: int,
68+ term: str) -> Mail:
69+ name = (display_name or "").strip()
70+ hello = f"Bonjour {html.escape(name)}," if name and "@" not in name else "Bonjour,"
71+ title = "Bienvenue sur UQO-Chat — choisis ton mot de passe"
72+ intro = (f"<p style='margin:0 0 10px'>{hello}</p>"
73+ "<p style='margin:0 0 10px'>Ton professeur t'a inscrit·e à <b>UQO-Chat</b>, le tuteur IA des cours "
74+ f"IMM1003 et IMM1033 ({html.escape(term)}). Il explique les notes de cours, corrige tes calculs "
75+ "d'évaluation immobilière et produit des classeurs Excel.</p>"
76+ f"<p style='margin:0'>Pour activer ton compte <b>{html.escape(to)}</b>, choisis un mot de passe. "
77+ "Tu l'utiliseras ensuite avec ton courriel à chaque connexion.</p>")
78+ footer = (f"<p style='margin:12px 0 0'>Ce lien est valide {ttl_days} jours et ne sert qu'une fois. "
79+ "S'il a expiré, clique sur « Première connexion ou mot de passe oublié » sur la page de connexion.</p>")
80+ text = (f"{hello.replace(',', ',')}\n\nTon professeur t'a inscrit·e à UQO-Chat, le tuteur IA des cours IMM1003 et "
81+ f"IMM1033 ({term}).\n\nPour activer ton compte {to}, choisis un mot de passe ici (valide {ttl_days} jours) :\n\n"
82+ f"{link}\n\nTu l'utiliseras ensuite avec ton courriel à chaque connexion. Si le lien a expiré, utilise "
83+ "« Première connexion ou mot de passe oublié » sur la page de connexion.\n\n— UQO-Chat")
84+ return Mail(to, title, text, _layout(title, intro, "Choisir mon mot de passe", link, footer))
85+
86+
87+def reset_mail(to: str, link: str, ttl_minutes: int, first_time: bool) -> Mail:
88+ if first_time:
89+ title = "Choisis ton mot de passe UQO-Chat"
90+ intro = ("<p style='margin:0 0 10px'>Bonjour,</p>"
91+ f"<p style='margin:0'>Tu as demandé à activer ton compte UQO-Chat <b>{html.escape(to)}</b>. "
92+ "Choisis un mot de passe pour te connecter.</p>")
93+ cta = "Choisir mon mot de passe"
94+ else:
95+ title = "Réinitialisation de ton mot de passe UQO-Chat"
96+ intro = ("<p style='margin:0 0 10px'>Bonjour,</p>"
97+ f"<p style='margin:0'>Une réinitialisation du mot de passe a été demandée pour "
98+ f"<b>{html.escape(to)}</b>. Clique ci-dessous pour en choisir un nouveau.</p>")
99+ cta = "Choisir un nouveau mot de passe"
100+ footer = (f"<p style='margin:12px 0 0'>Ce lien est valide {ttl_minutes} minutes et ne sert qu'une fois. "
101+ "Si tu n'as rien demandé, ignore ce courriel : ton mot de passe actuel reste inchangé.</p>")
102+ text = (f"Bonjour,\n\n{'Tu as demandé à activer ton compte' if first_time else 'Une réinitialisation du mot de passe a été demandée pour'} "
103+ f"UQO-Chat {to}.\n\nChoisis ton mot de passe ici (valide {ttl_minutes} minutes) :\n\n{link}\n\n"
104+ "Si tu n'as rien demandé, ignore ce courriel.\n\n— UQO-Chat")
105+ return Mail(to, title, text, _layout(title, intro, cta, link, footer))
106+
107+
108+def login_link_mail(to: str, link: str) -> Mail:
109+ title = "Connexion à UQO-Chat"
110+ intro = f"<p style='margin:0'>Voici ton lien de connexion à UQO-Chat pour <b>{html.escape(to)}</b>.</p>"
111+ footer = "<p style='margin:12px 0 0'>Si tu n'as pas demandé ce lien, ignore ce courriel.</p>"
112+ text = f"Bonjour,\n\nVoici ton lien de connexion à UQO-Chat :\n\n{link}\n\nSi tu n'as pas demandé ce lien, ignore ce courriel.\n\n— UQO-Chat"
113+ return Mail(to, title, text, _layout(title, intro, "Me connecter", link, footer))
114+
115+
116+# ------------------------------------------------------------------ transport
117+def _resend_payload(settings: Settings, m: Mail) -> dict:
118+ payload = {"from": settings.MAIL_FROM, "to": [m.to], "subject": m.subject,
119+ "text": m.text, "html": m.html}
120+ if settings.MAIL_REPLY_TO:
121+ payload["reply_to"] = settings.MAIL_REPLY_TO
122+ return payload
123+
124+
125+def _resend_headers(settings: Settings) -> dict[str, str]:
126+ return {"Authorization": f"Bearer {settings.RESEND_API_KEY.get_secret_value()}",
127+ "Content-Type": "application/json"}
128+
129+
130+async def _send_smtp(settings: Settings, m: Mail) -> bool:
131+ import aiosmtplib
14 132
15−async def send_magic_link(settings: Settings, to: str, link: str) -> bool:
16− if not settings.smtp_enabled:
17− log.info("magic_link_not_sent_no_smtp", to_domain=to.split("@")[-1])
18− return False
19 msg = EmailMessage() 133 msg = EmailMessage()
20 msg["From"] = settings.SMTP_FROM 134 msg["From"] = settings.SMTP_FROM
21− msg["To"] = to 135+ msg["To"] = m.to
22− msg["Subject"] = "Connexion à UQO-Chat" 136+ msg["Subject"] = m.subject
23− msg.set_content( 137+ msg.set_content(m.text)
24− "Bonjour,\n\nVoici ton lien de connexion à UQO-Chat (valide 20 minutes) :\n\n" 138+ msg.add_alternative(m.html, subtype="html")
25− f"{link}\n\nSi tu n'as pas demandé ce lien, ignore ce courriel.\n\n— UQO-Chat, "
26− "tuteur IA IMM1003 · IMM1033")
27 await aiosmtplib.send( 139 await aiosmtplib.send(
28 msg, hostname=settings.SMTP_HOST, port=settings.SMTP_PORT, 140 msg, hostname=settings.SMTP_HOST, port=settings.SMTP_PORT,
29 username=settings.SMTP_USER or None, 141 username=settings.SMTP_USER or None,
30 password=settings.SMTP_PASSWORD.get_secret_value() or None, 142 password=settings.SMTP_PASSWORD.get_secret_value() or None,
31 start_tls=settings.SMTP_PORT == 587) 143 start_tls=settings.SMTP_PORT == 587)
32 return True 144 return True
145+
146+
147+async def send(settings: Settings, m: Mail) -> bool:
148+ """Send one message. Returns False (and logs) when no transport is configured or it fails."""
149+ if settings.resend_enabled:
150+ try:
151+ async with httpx.AsyncClient(timeout=20) as client:
152+ r = await client.post(f"{settings.RESEND_BASE_URL}/emails",
153+ headers=_resend_headers(settings),
154+ json=_resend_payload(settings, m))
155+ if r.status_code < 300:
156+ log.info("mail_sent", provider="resend", to_domain=m.to.split("@")[-1])
157+ return True
158+ log.warning("mail_failed", provider="resend", status=r.status_code, body=r.text[:300])
159+ except httpx.HTTPError as exc:
160+ log.warning("mail_failed", provider="resend", error=str(exc))
161+ return False
162+ if settings.smtp_enabled:
163+ try:
164+ return await _send_smtp(settings, m)
165+ except Exception as exc: # noqa: BLE001
166+ log.warning("mail_failed", provider="smtp", error=str(exc))
167+ return False
168+ log.info("mail_not_sent_no_transport", to_domain=m.to.split("@")[-1])
169+ return False
170+
171+
172+async def send_batch(settings: Settings, mails: list[Mail]) -> dict[str, bool]:
173+ """Send many messages; Resend batch endpoint (100/call), otherwise one by one."""
174+ result: dict[str, bool] = {}
175+ if not mails:
176+ return result
177+ if settings.resend_enabled:
178+ async with httpx.AsyncClient(timeout=30) as client:
179+ for i in range(0, len(mails), 100):
180+ chunk = mails[i:i + 100]
181+ try:
182+ r = await client.post(f"{settings.RESEND_BASE_URL}/emails/batch",
183+ headers=_resend_headers(settings),
184+ json=[_resend_payload(settings, m) for m in chunk])
185+ ok = r.status_code < 300
186+ if not ok:
187+ log.warning("mail_batch_failed", status=r.status_code, body=r.text[:300])
188+ except httpx.HTTPError as exc:
189+ ok = False
190+ log.warning("mail_batch_failed", error=str(exc))
191+ if ok:
192+ for m in chunk:
193+ result[m.to] = True
194+ else: # degrade to unit sends so one bad address does not block the cohort
195+ for m in chunk:
196+ result[m.to] = await send(settings, m)
197+ log.info("mail_batch_sent", n=sum(result.values()), total=len(mails))
198+ return result
199+ for m in mails:
200+ result[m.to] = await send(settings, m)
201+ return result
202+
203+
204+# ------------------------------------------------------------------ convenience
205+async def send_magic_link(settings: Settings, to: str, link: str) -> bool:
206+ return await send(settings, login_link_mail(to, link))
modified backend/app/services/users.py +68 −14
@@ -78,25 +78,77 @@ async def set_consent(user_id: str) -> None:
78 await session.commit() 78 await session.commit()
79 79
80 80
81−async def create_magic_link(email: str, ttl_minutes: int = 20) -> str: 81+async def create_magic_link(email: str, ttl_minutes: int = 20, purpose: str = "login") -> str:
82+ """Single-use token. purpose: "login" (direct sign-in), "invite" / "reset" (choose a password)."""
82 token = new_magic_token() 83 token = new_magic_token()
83 async with SessionLocal() as session: 84 async with SessionLocal() as session:
84− session.add(MagicLink(token=token, email=email.lower().strip(), 85+ session.add(MagicLink(token=token, email=email.lower().strip(), purpose=purpose,
85 expires_at=utcnow() + timedelta(minutes=ttl_minutes))) 86 expires_at=utcnow() + timedelta(minutes=ttl_minutes)))
86 await session.commit() 87 await session.commit()
87 return token 88 return token
88 89
89 90
90−async def consume_magic_link(token: str) -> str | None: 91+def _link_valid(link: MagicLink | None) -> bool:
92+ return bool(link and not link.used_at and link.expires_at >= utcnow())
93+
94+
95+async def peek_magic_link(token: str) -> MagicLink | None:
96+ """Validate without consuming (used by the set-password page to show the e-mail)."""
97+ async with SessionLocal() as session:
98+ link = await session.get(MagicLink, token)
99+ return link if _link_valid(link) else None
100+
101+
102+async def consume_magic_link(token: str, purposes: set[str] | None = None) -> str | None:
91 async with SessionLocal() as session: 103 async with SessionLocal() as session:
92 link = await session.get(MagicLink, token) 104 link = await session.get(MagicLink, token)
93− if not link or link.used_at or link.expires_at < utcnow(): 105+ if not _link_valid(link):
106+ return None
107+ if purposes is not None and (link.purpose or "login") not in purposes:
94 return None 108 return None
95 link.used_at = utcnow() 109 link.used_at = utcnow()
96 await session.commit() 110 await session.commit()
97 return link.email 111 return link.email
98 112
99 113
114+async def invalidate_links(email: str, purposes: set[str] | None = None) -> int:
115+ """Burn every outstanding token of an address (after a password was set)."""
116+ async with SessionLocal() as session:
117+ rows = (await session.execute(select(MagicLink).where(
118+ MagicLink.email == email.lower().strip(), MagicLink.used_at.is_(None)))).scalars().all()
119+ n = 0
120+ for link in rows:
121+ if purposes is None or (link.purpose or "login") in purposes:
122+ link.used_at = utcnow()
123+ n += 1
124+ await session.commit()
125+ return n
126+
127+
128+async def get_user_by_email(email: str) -> User | None:
129+ async with SessionLocal() as session:
130+ return await session.scalar(select(User).where(User.email == email.lower().strip()))
131+
132+
133+async def mark_invited(user_ids: list[str]) -> None:
134+ if not user_ids:
135+ return
136+ async with SessionLocal() as session:
137+ rows = (await session.execute(select(User).where(User.id.in_(user_ids)))).scalars().all()
138+ now = utcnow()
139+ for u in rows:
140+ u.invited_at = now
141+ await session.commit()
142+
143+
144+async def users_without_password(only_never_invited: bool = False) -> list[User]:
145+ async with SessionLocal() as session:
146+ q = select(User).where(User.password_hash.is_(None)).order_by(User.email)
147+ if only_never_invited:
148+ q = q.where(User.invited_at.is_(None))
149+ return list((await session.execute(q)).scalars().all())
150+
151+
100 async def set_role(email: str, role: str) -> bool: 152 async def set_role(email: str, role: str) -> bool:
101 async with SessionLocal() as session: 153 async with SessionLocal() as session:
102 user = await session.scalar(select(User).where(User.email == email.lower().strip())) 154 user = await session.scalar(select(User).where(User.email == email.lower().strip()))
@@ -138,6 +190,7 @@ async def list_users() -> list[dict]:
138 rows = (await session.execute(select(User).order_by(User.role.desc(), User.email))).scalars() 190 rows = (await session.execute(select(User).order_by(User.role.desc(), User.email))).scalars()
139 return [{"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name, 191 return [{"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name,
140 "has_password": bool(u.password_hash), 192 "has_password": bool(u.password_hash),
193+ "invited_at": u.invited_at.isoformat() if u.invited_at else None,
141 "created_at": u.created_at.isoformat(), 194 "created_at": u.created_at.isoformat(),
142 "last_seen_at": u.last_seen_at.isoformat() if u.last_seen_at else None, 195 "last_seen_at": u.last_seen_at.isoformat() if u.last_seen_at else None,
143 "consent_at": u.consent_at.isoformat() if u.consent_at else None, 196 "consent_at": u.consent_at.isoformat() if u.consent_at else None,
@@ -147,6 +200,7 @@ async def list_users() -> list[dict]:
147 async def create_users(emails: list[str], settings: Settings, role: str = "student", 200 async def create_users(emails: list[str], settings: Settings, role: str = "student",
148 display_names: dict[str, str] | None = None) -> dict: 201 display_names: dict[str, str] | None = None) -> dict:
149 created, existing, invalid = [], [], [] 202 created, existing, invalid = [], [], []
203+ created_ids: list[str] = []
150 async with SessionLocal() as session: 204 async with SessionLocal() as session:
151 for raw in emails: 205 for raw in emails:
152 email = raw.strip().lower() 206 email = raw.strip().lower()
@@ -154,18 +208,23 @@ async def create_users(emails: list[str], settings: Settings, role: str = "stude
154 if raw.strip(): 208 if raw.strip():
155 invalid.append(raw.strip()) 209 invalid.append(raw.strip())
156 continue 210 continue
211+ if email in created:
212+ continue
157 user = await session.scalar(select(User).where(User.email == email)) 213 user = await session.scalar(select(User).where(User.email == email))
158 if user: 214 if user:
159 existing.append(email) 215 existing.append(email)
160 continue 216 continue
161 r = role_for_email(email, settings) 217 r = role_for_email(email, settings)
162− session.add(User(email=email, role=r if r != "student" else role, 218+ u = User(email=email, role=r if r != "student" else role,
163− display_name=(display_names or {}).get(email) or email.split("@")[0], 219+ display_name=(display_names or {}).get(email) or email.split("@")[0],
164− preferences={"tutoiement": True, "course": settings.courses[0], 220+ preferences={"tutoiement": True, "course": settings.courses[0],
165− "deep": False, "locale": "fr-CA"})) 221+ "deep": False, "locale": "fr-CA"})
222+ session.add(u)
166 created.append(email) 223 created.append(email)
224+ await session.flush()
225+ created_ids.append(u.id)
167 await session.commit() 226 await session.commit()
168− return {"created": created, "existing": existing, "invalid": invalid} 227+ return {"created": created, "existing": existing, "invalid": invalid, "created_ids": created_ids}
169 228
170 229
171 async def email_registered(email: str) -> bool: 230 async def email_registered(email: str) -> bool:
@@ -189,11 +248,6 @@ async def set_setting(key: str, value: str) -> None:
189 await session.commit() 248 await session.commit()
190 249
191 250
192−async def effective_access_code(settings: Settings) -> str:
193− override = await get_setting("access_code")
194− return override if override is not None else settings.ACCESS_CODE
195−
196−
197 # ------------------------------------------------------------------ passwords 251 # ------------------------------------------------------------------ passwords
198 async def set_password(user_id: str, password: str) -> bool: 252 async def set_password(user_id: str, password: str) -> bool:
199 async with SessionLocal() as session: 253 async with SessionLocal() as session:
added backend/tests/test_auth_invitations.py +139 −0
@@ -0,0 +1,139 @@
1+"""Invitation → choose password → sign-in → forgot password, with Resend mocked (respx)."""
2+
3+from __future__ import annotations
4+
5+import json
6+import os
7+
8+import httpx
9+import pytest
10+import respx
11+
12+os.environ["RESEND_API_KEY"] = "re_test"
13+os.environ["RESEND_BASE_URL"] = "https://resend.test"
14+os.environ["APP_URL"] = "https://chat.test"
15+os.environ["PROFESSOR_EMAILS"] = "prof@uqo.ca"
16+os.environ["INVITED_EMAILS"] = ""
17+
18+from app.core.config import get_settings # noqa: E402
19+from app.db import init_db # noqa: E402
20+from app.main import app # noqa: E402
21+from app.services import users # noqa: E402
22+
23+
24+@pytest.fixture(scope="module", autouse=True)
25+async def _db() -> None:
26+ get_settings.cache_clear()
27+ await init_db()
28+
29+
30+@pytest.fixture
31+async def client() -> httpx.AsyncClient:
32+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as c:
33+ yield c
34+
35+
36+async def _professor(client: httpx.AsyncClient) -> dict[str, str]:
37+ settings = get_settings()
38+ prof = await users.get_or_create_user("prof@uqo.ca", settings)
39+ await users.set_password(prof.id, "prof-secret-1")
40+ r = await client.post("/api/v1/auth/login", json={"email": "prof@uqo.ca", "password": "prof-secret-1"})
41+ assert r.status_code == 200, r.text
42+ return {"Authorization": f"Bearer {r.json()['token']}"}
43+
44+
45+def _link_from(payload: dict) -> str:
46+ return payload["html"].split('href="')[1].split('"')[0]
47+
48+
49+@pytest.mark.asyncio
50+async def test_invite_set_password_login_and_forgot(client: httpx.AsyncClient) -> None:
51+ headers = await _professor(client)
52+ with respx.mock(base_url="https://resend.test") as mock:
53+ batch = mock.post("/emails/batch").mock(return_value=httpx.Response(200, json={"data": [{"id": "1"}, {"id": "2"}]}))
54+ single = mock.post("/emails").mock(return_value=httpx.Response(200, json={"id": "3"}))
55+
56+ # 1. professor registers two students → one batch call, both invited
57+ r = await client.post("/api/v1/professor/students", headers=headers,
58+ json={"emails": "Marie Tremblay <tremblay.marie@uqo.ca>\nlebel.paul@uqo.ca"})
59+ assert r.status_code == 201, r.text
60+ body = r.json()
61+ assert sorted(body["created"]) == ["lebel.paul@uqo.ca", "tremblay.marie@uqo.ca"]
62+ assert sorted(body["invited"]) == ["lebel.paul@uqo.ca", "tremblay.marie@uqo.ca"]
63+ assert batch.call_count == 1
64+ sent = json.loads(batch.calls[0].request.content)
65+ assert len(sent) == 2 and sent[0]["from"].endswith("<no-reply@uqo-chat.app>")
66+ assert "Bonjour Marie Tremblay" in sent[0]["html"]
67+ link = _link_from(sent[0])
68+ assert link.startswith("https://chat.test/mot-de-passe?token=")
69+ token = link.split("token=")[1]
70+
71+ # invited_at stamped, no password yet
72+ rows = (await client.get("/api/v1/professor/students", headers=headers)).json()
73+ marie = next(u for u in rows["users"] if u["email"] == "tremblay.marie@uqo.ca")
74+ assert marie["invited_at"] and not marie["has_password"]
75+ assert rows["pending"] >= 2 and rows["mail"] is True
76+
77+ # 2. password login refused before activation, with an explicit hint
78+ r = await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "whatever1"})
79+ assert r.status_code == 401 and "pas encore de mot de passe" in r.json()["detail"]
80+
81+ # 3. token info, then set password (too short → 400; ok → session)
82+ r = await client.get("/api/v1/auth/password-token", params={"token": token})
83+ assert r.status_code == 200 and r.json()["first_time"] is True
84+ r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "short"})
85+ assert r.status_code == 400
86+ r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "Marie-2026!"})
87+ assert r.status_code == 200, r.text
88+ assert r.json()["user"]["has_password"] is True
89+ # token single-use
90+ r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "Marie-2026!"})
91+ assert r.status_code == 400
92+
93+ # 4. sign in with the password
94+ r = await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Marie-2026!"})
95+ assert r.status_code == 200
96+ me = await client.get("/api/v1/me", headers={"Authorization": f"Bearer {r.json()['token']}"})
97+ assert me.json()["email"] == "tremblay.marie@uqo.ca"
98+
99+ # 5. forgot password → single e-mail with a reset link that changes the password
100+ r = await client.post("/api/v1/auth/forgot", json={"email": "tremblay.marie@uqo.ca"})
101+ assert r.status_code == 200 and r.json()["sent"] is True and r.json()["first_time"] is False
102+ assert single.call_count == 1
103+ reset_link = _link_from(json.loads(single.calls[0].request.content))
104+ r = await client.post("/api/v1/auth/set-password", json={"token": reset_link.split("token=")[1], "password": "Nouveau-2026!"})
105+ assert r.status_code == 200
106+ assert (await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Marie-2026!"})).status_code == 401
107+ assert (await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Nouveau-2026!"})).status_code == 200
108+
109+ # 6. unknown @uqo.ca address is refused (only the professor's list may sign in)
110+ r = await client.post("/api/v1/auth/forgot", json={"email": "inconnu@uqo.ca"})
111+ assert r.status_code == 404 and "pas inscrite" in r.json()["detail"]
112+ r = await client.post("/api/v1/auth/forgot", json={"email": "x@gmail.com"})
113+ assert r.status_code == 404
114+
115+ # 7. invite-all only targets accounts without a password never invited before
116+ r = await client.post("/api/v1/professor/students/invite-all", headers=headers, json={"only_never_invited": True})
117+ assert r.status_code == 200 and r.json()["total"] == 0
118+ r = await client.post("/api/v1/professor/students/invite-all", headers=headers, json={"only_never_invited": False})
119+ assert r.status_code == 200 and "lebel.paul@uqo.ca" in r.json()["sent"]
120+ assert "tremblay.marie@uqo.ca" not in r.json()["sent"]
121+
122+ # 8. per-student resend returns the link too
123+ paul = next(u for u in rows["users"] if u["email"] == "lebel.paul@uqo.ca")
124+ r = await client.post(f"/api/v1/professor/students/{paul['id']}/invite", headers=headers)
125+ assert r.status_code == 200 and r.json()["sent"] is True and "/mot-de-passe?token=" in r.json()["link"]
126+
127+
128+@pytest.mark.asyncio
129+async def test_batch_failure_degrades_to_unit_sends(client: httpx.AsyncClient) -> None:
130+ headers = await _professor(client)
131+ with respx.mock(base_url="https://resend.test") as mock:
132+ mock.post("/emails/batch").mock(return_value=httpx.Response(422, json={"message": "bad"}))
133+ single = mock.post("/emails").mock(side_effect=[httpx.Response(200, json={"id": "a"}),
134+ httpx.Response(403, json={"message": "no"})])
135+ r = await client.post("/api/v1/professor/students", headers=headers,
136+ json={"emails": ["ok@uqo.ca", "ko@uqo.ca"]})
137+ assert r.status_code == 201
138+ assert r.json()["invited"] == ["ok@uqo.ca"] and r.json()["invite_failed"] == ["ko@uqo.ca"]
139+ assert single.call_count == 2
modified frontend/src/app/router.tsx +2 −0
@@ -6,6 +6,7 @@ import { AppShell } from '@/components/layout/app-shell';
6 import { ChatView } from '@/components/chat/chat-view'; 6 import { ChatView } from '@/components/chat/chat-view';
7 import { LoginPage } from '@/features/auth/login-page'; 7 import { LoginPage } from '@/features/auth/login-page';
8 import { PrivacyPage } from '@/features/auth/privacy-page'; 8 import { PrivacyPage } from '@/features/auth/privacy-page';
9+import { SetPasswordPage } from '@/features/auth/set-password-page';
9 import { ProfessorPage } from '@/features/professor/professor-page'; 10 import { ProfessorPage } from '@/features/professor/professor-page';
10 import { AdminCostsPage } from '@/features/professor/admin-costs-page'; 11 import { AdminCostsPage } from '@/features/professor/admin-costs-page';
11 import { Spinner } from '@/components/ui/spinner'; 12 import { Spinner } from '@/components/ui/spinner';
@@ -32,6 +33,7 @@ export function AppRouter() {
32 return ( 33 return (
33 <Routes> 34 <Routes>
34 <Route path="/connexion" element={<LoginPage />} /> 35 <Route path="/connexion" element={<LoginPage />} />
36+ <Route path="/mot-de-passe" element={<SetPasswordPage />} />
35 <Route path="/confidentialite" element={<PrivacyPage />} /> 37 <Route path="/confidentialite" element={<PrivacyPage />} />
36 <Route path="/professeur" element={<Guard role="professor"><ProfessorPage /></Guard>} /> 38 <Route path="/professeur" element={<Guard role="professor"><ProfessorPage /></Guard>} />
37 <Route path="/admin" element={<Guard role="admin"><AdminCostsPage /></Guard>} /> 39 <Route path="/admin" element={<Guard role="admin"><AdminCostsPage /></Guard>} />
modified frontend/src/features/auth/login-page.tsx +69 −80
@@ -1,7 +1,7 @@
1 import { useEffect, useState, type FormEvent } from 'react'; 1 import { useEffect, useState, type FormEvent } from 'react';
2−import { useNavigate, useSearchParams } from 'react-router-dom'; 2+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
3 import { useQuery } from '@tanstack/react-query'; 3 import { useQuery } from '@tanstack/react-query';
4−import { KeyRound, Lock, Mail, ShieldCheck } from 'lucide-react'; 4+import { ArrowLeft, Lock, Mail, MailCheck, ShieldCheck } from 'lucide-react';
5 import { api, setToken } from '@/lib/api'; 5 import { api, setToken } from '@/lib/api';
6 import type { User } from '@/lib/types'; 6 import type { User } from '@/lib/types';
7 import { useAuth } from '@/stores/auth'; 7 import { useAuth } from '@/stores/auth';
@@ -9,28 +9,25 @@ import { Logo } from '@/components/ui/logo';
9 import { Button } from '@/components/ui/button'; 9 import { Button } from '@/components/ui/button';
10 import { Spinner } from '@/components/ui/spinner'; 10 import { Spinner } from '@/components/ui/spinner';
11 11
12−interface AuthConfig { smtp: boolean; access_code: boolean; password?: boolean; domains: string[]; courses: string[]; term: string } 12+export interface AuthConfig { mail: boolean; password: boolean; min_password: number; domains: string[]; courses: string[]; term: string }
13−interface LoginResp { mode: string; token?: string; user?: User; sent?: boolean; dev_link?: string; hint?: string } 13+interface LoginResp { mode: string; token?: string; user?: User }
14+interface ForgotResp { sent: boolean; first_time: boolean; ttl_minutes: number; dev_link?: string; hint?: string }
14 15
15 export function LoginPage() { 16 export function LoginPage() {
16 const nav = useNavigate(); 17 const nav = useNavigate();
17 const [params] = useSearchParams(); 18 const [params] = useSearchParams();
18 const setUser = useAuth((s) => s.setUser); 19 const setUser = useAuth((s) => s.setUser);
19 const { data: cfg } = useQuery({ queryKey: ['auth-config'], queryFn: () => api<AuthConfig>('/auth/config') }); 20 const { data: cfg } = useQuery({ queryKey: ['auth-config'], queryFn: () => api<AuthConfig>('/auth/config') });
20− const [email, setEmail] = useState(''); 21+ const [email, setEmail] = useState(params.get('email') || '');
21− const [code, setCode] = useState('');
22− const [mode, setMode] = useState<'code' | 'link' | 'password'>('code');
23 const [password, setPassword] = useState(''); 22 const [password, setPassword] = useState('');
23+ const [mode, setMode] = useState<'password' | 'forgot'>(params.get('mode') === 'forgot' ? 'forgot' : 'password');
24 const [busy, setBusy] = useState(false); 24 const [busy, setBusy] = useState(false);
25 const [error, setError] = useState<string | null>(null); 25 const [error, setError] = useState<string | null>(null);
26− const [info, setInfo] = useState<string | null>(null); 26+ const [sent, setSent] = useState<ForgotResp | null>(null);
27 const [consent, setConsent] = useState(false); 27 const [consent, setConsent] = useState(false);
28 const token = params.get('token'); 28 const token = params.get('token');
29 29
30− useEffect(() => { 30+ // Legacy direct sign-in links (/connexion?token=…) sent before the password flow.
31− if (cfg) setMode(cfg.access_code ? 'code' : 'link');
32− }, [cfg]);
33−
34 useEffect(() => { 31 useEffect(() => {
35 if (!token) return; 32 if (!token) return;
36 setBusy(true); 33 setBusy(true);
@@ -43,34 +40,20 @@ export function LoginPage() {
43 const submit = async (e: FormEvent) => { 40 const submit = async (e: FormEvent) => {
44 e.preventDefault(); 41 e.preventDefault();
45 setError(null); 42 setError(null);
46− setInfo(null); 43+ setSent(null);
47− if (!consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; }
48 setBusy(true); 44 setBusy(true);
49 try { 45 try {
50− if (mode === 'password') { 46+ if (mode === 'forgot') {
51− const r = await api<LoginResp>('/auth/login', { method: 'POST', body: JSON.stringify({ email: email.trim(), password }) }); 47+ const r = await api<ForgotResp>('/auth/forgot', { method: 'POST', body: JSON.stringify({ email: email.trim() }) });
52− if (r.token) setToken(r.token); 48+ setSent(r);
53− if (r.user) setUser(r.user);
54− await api('/me/consent', { method: 'POST' }).catch(() => undefined);
55− nav('/', { replace: true });
56 return; 49 return;
57 } 50 }
58− const body: Record<string, string> = { email: email.trim() }; 51+ if (!consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; }
59− if (mode === 'code') body.access_code = code.trim(); 52+ const r = await api<LoginResp>('/auth/login', { method: 'POST', body: JSON.stringify({ email: email.trim(), password }) });
60− const r = await api<LoginResp>('/auth/magic-link', { method: 'POST', body: JSON.stringify(body) }); 53+ if (r.token) setToken(r.token);
61− if (r.token && r.user) { 54+ if (r.user) setUser(r.user);
62− setToken(r.token); 55+ await api('/me/consent', { method: 'POST' }).catch(() => undefined);
63− setUser(r.user); 56+ nav('/', { replace: true });
64− await api('/me/consent', { method: 'POST' }).catch(() => undefined);
65− nav('/', { replace: true });
66− } else if (r.sent) {
67− setInfo('Un lien de connexion vient d\'être envoyé. Vérifie ta boîte de courriel UQO (et les indésirables).');
68− } else if (r.dev_link) {
69− setInfo(`Mode développement — lien : ${r.dev_link}`);
70− } else {
71− setInfo(r.hint || 'Envoi de courriel indisponible. Utilise le code d\'accès du cours.');
72− if (cfg?.access_code) setMode('code');
73− }
74 } catch (e) { 57 } catch (e) {
75 setError(e instanceof Error ? e.message : 'Connexion impossible.'); 58 setError(e instanceof Error ? e.message : 'Connexion impossible.');
76 } finally { 59 } finally {
@@ -89,53 +72,59 @@ export function LoginPage() {
89 </div> 72 </div>
90 <div className="flex-1 px-4 -mt-9 pb-[calc(24px+var(--safe-bottom))]"> 73 <div className="flex-1 px-4 -mt-9 pb-[calc(24px+var(--safe-bottom))]">
91 <form onSubmit={submit} className="relative z-10 mx-auto max-w-[440px] rounded-[24px] bg-white shadow-float p-6 space-y-4 animate-fadein"> 74 <form onSubmit={submit} className="relative z-10 mx-auto max-w-[440px] rounded-[24px] bg-white shadow-float p-6 space-y-4 animate-fadein">
92− <div> 75+ {mode === 'password' ? (
93− <h1 className="text-xl font-bold text-uqo-blue-dark">Connexion</h1> 76+ <div>
94− <p className="text-sm text-neutral-muted mt-1">IMM1003 · IMM1033 · {cfg?.term || ''}. Réservé aux adresses <b>@{cfg?.domains?.[0] || 'uqo.ca'}</b> et aux comptes créés par le professeur.</p> 77+ <h1 className="text-xl font-bold text-uqo-blue-dark">Connexion</h1>
95− </div> 78+ <p className="text-sm text-neutral-muted mt-1">IMM1003 · IMM1033 · {cfg?.term || ''}. Réservé aux étudiant·es inscrit·es par le professeur (courriel <b>@{cfg?.domains?.[0] || 'uqo.ca'}</b>).</p>
96− {token && busy && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>} 79+ </div>
97− <label className="block"> 80+ ) : (
98− <span className="text-sm font-medium">Courriel UQO</span> 81+ <div>
99− <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue"> 82+ <button type="button" onClick={() => { setMode('password'); setSent(null); setError(null); }} className="inline-flex items-center gap-1 text-sm text-uqo-blue min-h-[32px]"><ArrowLeft size={16} /> Retour</button>
100− <Mail size={18} className="ml-3 text-neutral-muted" /> 83+ <h1 className="text-xl font-bold text-uqo-blue-dark mt-1">Première connexion ou mot de passe oublié</h1>
101− <input type="email" required autoComplete="email" inputMode="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="prenom.nom@uqo.ca" className="h-12 flex-1 bg-transparent px-3 outline-none" /> 84+ <p className="text-sm text-neutral-muted mt-1">Saisis ton courriel UQO : tu recevras un lien pour choisir ton mot de passe. Le lien est valide {sent?.ttl_minutes ?? 60} minutes.</p>
102− </span> 85+ </div>
103− </label>
104− <div className="grid grid-cols-2 gap-1 rounded-xl bg-neutral-surface p-1 text-sm" role="tablist">
105− <button type="button" role="tab" aria-selected={mode !== 'password'} onClick={() => setMode(cfg?.access_code ? 'code' : 'link')} className={`h-10 rounded-lg font-medium ${mode !== 'password' ? 'bg-white shadow-card text-uqo-blue-dark' : 'text-neutral-muted'}`}>Étudiant · code du cours</button>
106− <button type="button" role="tab" aria-selected={mode === 'password'} onClick={() => setMode('password')} className={`h-10 rounded-lg font-medium ${mode === 'password' ? 'bg-white shadow-card text-uqo-blue-dark' : 'text-neutral-muted'}`}>Mot de passe</button>
107− </div>
108− {mode === 'password' && (
109− <label className="block">
110− <span className="text-sm font-medium">Mot de passe</span>
111− <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">
112− <Lock size={18} className="ml-3 text-neutral-muted" />
113− <input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" className="h-12 flex-1 bg-transparent px-3 outline-none" />
114− </span>
115− </label>
116− )}
117− {mode === 'code' && (
118− <label className="block">
119− <span className="text-sm font-medium">Code d'accès du cours</span>
120− <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">
121− <KeyRound size={18} className="ml-3 text-neutral-muted" />
122− <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} placeholder="Fourni par le professeur" autoComplete="one-time-code" className="h-12 flex-1 bg-transparent px-3 outline-none" />
123− </span>
124− </label>
125 )} 86 )}
126− <label className="flex items-start gap-2 text-sm"> 87+ {token && busy && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>}
127− <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} className="mt-1 h-4 w-4 accent-uqo-blue" /> 88+ {sent ? (
128− <span>J'ai lu la <a href="/confidentialite" target="_blank" className="text-uqo-blue underline">politique de confidentialité</a> et j'accepte que mes conversations soient traitées pour m'aider dans le cours (Loi 25).</span> 89+ <div className="rounded-2xl bg-uqo-blue-light p-4 text-sm text-uqo-blue-dark space-y-2">
129− </label> 90+ <div className="flex items-center gap-2 font-semibold"><MailCheck size={18} /> {sent.sent ? 'Courriel envoyé' : 'Courriel non envoyé'}</div>
130− {error && <p className="text-sm text-semantic-error">{error}</p>} 91+ {sent.sent && <p>Ouvre le message « {sent.first_time ? 'Choisis ton mot de passe UQO-Chat' : 'Réinitialisation de ton mot de passe UQO-Chat'} » envoyé à <b>{email.trim()}</b> et clique sur le bouton. Pense à vérifier les indésirables.</p>}
131− {info && <p className="text-sm text-uqo-blue-dark bg-uqo-blue-light rounded-lg p-3 break-all">{info}</p>} 92+ {sent.hint && <p>{sent.hint}</p>}
132− <Button type="submit" size="lg" className="w-full" disabled={busy}>{busy ? <Spinner className="text-white" /> : mode === 'link' ? 'Recevoir un lien de connexion' : 'Se connecter'}</Button> 93+ {sent.dev_link && <p className="break-all">Mode développement — lien : <a className="underline" href={sent.dev_link}>{sent.dev_link}</a></p>}
133− {cfg?.access_code && cfg?.smtp && mode !== 'password' && ( 94+ <button type="button" onClick={() => { setMode('password'); setSent(null); }} className="text-uqo-blue underline min-h-[32px]">Retour à la connexion</button>
134− <button type="button" onClick={() => setMode(mode === 'code' ? 'link' : 'code')} className="w-full text-sm text-uqo-blue underline min-h-[44px]"> 95+ </div>
135− {mode === 'code' ? 'Recevoir plutôt un lien par courriel' : 'Utiliser plutôt le code d\'accès du cours'} 96+ ) : (
136− </button> 97+ <>
98+ <label className="block">
99+ <span className="text-sm font-medium">Courriel UQO</span>
100+ <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">
101+ <Mail size={18} className="ml-3 text-neutral-muted" />
102+ <input type="email" required autoComplete="username" inputMode="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="prenom.nom@uqo.ca" className="h-12 flex-1 bg-transparent px-3 outline-none" />
103+ </span>
104+ </label>
105+ {mode === 'password' && (
106+ <label className="block">
107+ <span className="flex items-baseline justify-between text-sm font-medium">Mot de passe
108+ <button type="button" onClick={() => { setMode('forgot'); setError(null); }} className="text-xs font-normal text-uqo-blue underline min-h-[24px]">Première connexion ou mot de passe oublié ?</button>
109+ </span>
110+ <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">
111+ <Lock size={18} className="ml-3 text-neutral-muted" />
112+ <input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" className="h-12 flex-1 bg-transparent px-3 outline-none" />
113+ </span>
114+ </label>
115+ )}
116+ {mode === 'password' && (
117+ <label className="flex items-start gap-2 text-sm">
118+ <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} className="mt-1 h-4 w-4 accent-uqo-blue" />
119+ <span>J'ai lu la <a href="/confidentialite" target="_blank" className="text-uqo-blue underline">politique de confidentialité</a> et j'accepte que mes conversations soient traitées pour m'aider dans le cours (Loi 25).</span>
120+ </label>
121+ )}
122+ {error && <p className="text-sm text-semantic-error">{error}</p>}
123+ <Button type="submit" size="lg" className="w-full" disabled={busy}>{busy ? <Spinner className="text-white" /> : mode === 'forgot' ? 'Recevoir le lien par courriel' : 'Se connecter'}</Button>
124+ </>
137 )} 125 )}
138 <p className="text-xs text-neutral-muted flex items-start gap-1.5"><ShieldCheck size={14} className="shrink-0 mt-0.5" /> Outil pédagogique. Ne remplace pas un évaluateur agréé (É.A.) de l'OEAQ. L'utilisation de l'IA doit être déclarée dans tes travaux, conformément au plan de cours.</p> 126 <p className="text-xs text-neutral-muted flex items-start gap-1.5"><ShieldCheck size={14} className="shrink-0 mt-0.5" /> Outil pédagogique. Ne remplace pas un évaluateur agréé (É.A.) de l'OEAQ. L'utilisation de l'IA doit être déclarée dans tes travaux, conformément au plan de cours.</p>
127+ <p className="text-xs text-neutral-muted">Pas encore de compte ? Ton professeur doit ajouter ton courriel dans UQO-Chat ; tu recevras alors une invitation. <Link to="/confidentialite" className="underline">Confidentialité</Link></p>
139 </form> 128 </form>
140 </div> 129 </div>
141 </div> 130 </div>
added frontend/src/features/auth/set-password-page.tsx +135 −0
@@ -0,0 +1,135 @@
1+import { useState, type FormEvent } from 'react';
2+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
3+import { useQuery } from '@tanstack/react-query';
4+import { CheckCircle2, Eye, EyeOff, Lock, ShieldCheck, XCircle } from 'lucide-react';
5+import { api, ApiError, setToken } from '@/lib/api';
6+import type { User } from '@/lib/types';
7+import { useAuth } from '@/stores/auth';
8+import { Logo } from '@/components/ui/logo';
9+import { Button } from '@/components/ui/button';
10+import { Spinner } from '@/components/ui/spinner';
11+
12+interface TokenInfo { email: string; purpose: 'invite' | 'reset'; first_time: boolean; display_name: string | null; expires_at: string }
13+interface SetResp { token?: string; user?: User }
14+
15+const MIN = 8;
16+
17+function strength(pw: string): { score: number; label: string } {
18+ let s = 0;
19+ if (pw.length >= MIN) s++;
20+ if (pw.length >= 12) s++;
21+ if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;
22+ if (/\d/.test(pw)) s++;
23+ if (/[^A-Za-z0-9]/.test(pw)) s++;
24+ const label = pw.length < MIN ? 'Trop court' : s <= 2 ? 'Faible' : s === 3 ? 'Correct' : s === 4 ? 'Bon' : 'Excellent';
25+ return { score: pw.length < MIN ? 0 : s, label };
26+}
27+
28+/** /mot-de-passe?token=… — first activation (invitation) or password reset. */
29+export function SetPasswordPage() {
30+ const nav = useNavigate();
31+ const [params] = useSearchParams();
32+ const token = params.get('token') || '';
33+ const setUser = useAuth((s) => s.setUser);
34+ const info = useQuery({
35+ queryKey: ['password-token', token],
36+ queryFn: () => api<TokenInfo>(`/auth/password-token?token=${encodeURIComponent(token)}`),
37+ enabled: !!token,
38+ retry: false,
39+ });
40+ const [pw, setPw] = useState('');
41+ const [pw2, setPw2] = useState('');
42+ const [show, setShow] = useState(false);
43+ const [consent, setConsent] = useState(false);
44+ const [busy, setBusy] = useState(false);
45+ const [error, setError] = useState<string | null>(null);
46+ const st = strength(pw);
47+ const firstTime = info.data?.first_time ?? true;
48+
49+ const submit = async (e: FormEvent) => {
50+ e.preventDefault();
51+ setError(null);
52+ if (pw.length < MIN) { setError(`Le mot de passe doit contenir au moins ${MIN} caractères.`); return; }
53+ if (pw !== pw2) { setError('Les deux mots de passe ne correspondent pas.'); return; }
54+ if (firstTime && !consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; }
55+ setBusy(true);
56+ try {
57+ const r = await api<SetResp>('/auth/set-password', { method: 'POST', body: JSON.stringify({ token, password: pw }) });
58+ if (r.token) setToken(r.token);
59+ if (r.user) setUser(r.user);
60+ if (firstTime) await api('/me/consent', { method: 'POST' }).catch(() => undefined);
61+ nav('/', { replace: true });
62+ } catch (err) {
63+ setError(err instanceof Error ? err.message : 'Impossible d\'enregistrer le mot de passe.');
64+ } finally {
65+ setBusy(false);
66+ }
67+ };
68+
69+ const invalid = !token || (info.isError && (info.error as ApiError | undefined)?.status === 400) || info.isError;
70+ const title = info.data ? (firstTime ? 'Choisis ton mot de passe' : 'Nouveau mot de passe') : 'Mot de passe';
71+
72+ return (
73+ <div className="min-h-[100dvh] flex flex-col bg-neutral-surface">
74+ <div className="relative overflow-hidden bg-uqo-gradient text-white px-6 pt-[calc(28px+var(--safe-top))] pb-16">
75+ <svg className="absolute -right-16 -top-16 h-72 w-72 opacity-10" viewBox="0 0 200 200" aria-hidden="true"><circle cx="100" cy="100" r="100" fill="white" /></svg>
76+ <div className="mx-auto max-w-[440px]">
77+ <Logo inverted size="lg" />
78+ <p className="mt-5 text-[15px] text-white/85 max-w-[380px]">{firstTime ? 'Bienvenue ! Une dernière étape avant de commencer avec ton tuteur.' : 'Choisis un nouveau mot de passe pour ton compte.'}</p>
79+ </div>
80+ </div>
81+ <div className="flex-1 px-4 -mt-9 pb-[calc(24px+var(--safe-bottom))]">
82+ <form onSubmit={submit} className="relative z-10 mx-auto max-w-[440px] rounded-[24px] bg-white shadow-float p-6 space-y-4 animate-fadein">
83+ {info.isLoading && token && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>}
84+ {invalid && !info.isLoading && (
85+ <div className="space-y-3">
86+ <div className="flex items-center gap-2 text-semantic-error font-semibold"><XCircle size={20} /> Lien invalide ou expiré</div>
87+ <p className="text-sm text-neutral-muted">Les liens ne servent qu'une fois et expirent. Demande un nouveau lien : il arrivera par courriel en quelques secondes.</p>
88+ <Link to="/connexion?mode=forgot" className="block"><Button type="button" size="lg" className="w-full">Demander un nouveau lien</Button></Link>
89+ <Link to="/connexion" className="block text-center text-sm text-uqo-blue underline min-h-[32px]">Retour à la connexion</Link>
90+ </div>
91+ )}
92+ {info.data && (
93+ <>
94+ <div>
95+ <h1 className="text-xl font-bold text-uqo-blue-dark">{title}</h1>
96+ <p className="text-sm text-neutral-muted mt-1">Compte <b>{info.data.email}</b>{info.data.display_name && !info.data.display_name.includes('@') ? ` · ${info.data.display_name}` : ''}. Tu te connecteras ensuite avec ce courriel et ce mot de passe.</p>
97+ </div>
98+ <label className="block">
99+ <span className="text-sm font-medium">Mot de passe</span>
100+ <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">
101+ <Lock size={18} className="ml-3 text-neutral-muted" />
102+ <input type={show ? 'text' : 'password'} required minLength={MIN} value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" placeholder={`${MIN} caractères minimum`} className="h-12 flex-1 bg-transparent px-3 outline-none" />
103+ <button type="button" onClick={() => setShow((s) => !s)} className="h-12 w-12 inline-flex items-center justify-center text-neutral-muted" aria-label={show ? 'Masquer' : 'Afficher'}>{show ? <EyeOff size={18} /> : <Eye size={18} />}</button>
104+ </span>
105+ {pw && (
106+ <span className="mt-2 flex items-center gap-2 text-xs text-neutral-muted">
107+ <span className="flex gap-1 flex-1">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={`h-1.5 flex-1 rounded-full ${i <= st.score ? (st.score <= 2 ? 'bg-semantic-error' : st.score === 3 ? 'bg-amber-400' : 'bg-uqo-green') : 'bg-neutral-line'}`} />)}</span>
108+ <span className="w-20 text-right">{st.label}</span>
109+ </span>
110+ )}
111+ </label>
112+ <label className="block">
113+ <span className="text-sm font-medium">Confirme le mot de passe</span>
114+ <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">
115+ <Lock size={18} className="ml-3 text-neutral-muted" />
116+ <input type={show ? 'text' : 'password'} required value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" className="h-12 flex-1 bg-transparent px-3 outline-none" />
117+ {pw2 && (pw === pw2 ? <CheckCircle2 size={18} className="mr-3 text-uqo-green" /> : <XCircle size={18} className="mr-3 text-semantic-error" />)}
118+ </span>
119+ </label>
120+ {firstTime && (
121+ <label className="flex items-start gap-2 text-sm">
122+ <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} className="mt-1 h-4 w-4 accent-uqo-blue" />
123+ <span>J'ai lu la <a href="/confidentialite" target="_blank" className="text-uqo-blue underline">politique de confidentialité</a> et j'accepte que mes conversations soient traitées pour m'aider dans le cours (Loi 25).</span>
124+ </label>
125+ )}
126+ {error && <p className="text-sm text-semantic-error">{error}</p>}
127+ <Button type="submit" size="lg" className="w-full" disabled={busy || pw.length < MIN || pw !== pw2}>{busy ? <Spinner className="text-white" /> : firstTime ? 'Activer mon compte' : 'Enregistrer le mot de passe'}</Button>
128+ <p className="text-xs text-neutral-muted flex items-start gap-1.5"><ShieldCheck size={14} className="shrink-0 mt-0.5" /> Mot de passe chiffré (PBKDF2). Personne, pas même le professeur, ne peut le lire.</p>
129+ </>
130+ )}
131+ </form>
132+ </div>
133+ </div>
134+ );
135+}
modified frontend/src/features/auth/settings-dialog.tsx +1 −1
@@ -65,7 +65,7 @@ export function SettingsDialog() {
65 </Row> 65 </Row>
66 <div className="mt-4 rounded-xl border border-neutral-line p-4 text-sm"> 66 <div className="mt-4 rounded-xl border border-neutral-line p-4 text-sm">
67 <div className="font-medium">{user.has_password ? 'Changer mon mot de passe' : 'Définir un mot de passe'}</div> 67 <div className="font-medium">{user.has_password ? 'Changer mon mot de passe' : 'Définir un mot de passe'}</div>
68− <p className="text-neutral-muted mt-1">Permet de te connecter avec courriel + mot de passe, sans code du cours.</p> 68+ <p className="text-neutral-muted mt-1">Tu te connectes avec ton courriel et ce mot de passe. Oublié ? Utilise « Première connexion ou mot de passe oublié » sur la page de connexion.</p>
69 <div className="mt-2 grid gap-2 sm:grid-cols-2"> 69 <div className="mt-2 grid gap-2 sm:grid-cols-2">
70 {user.has_password && <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} placeholder="Mot de passe actuel" autoComplete="current-password" className="h-11 rounded-xl border border-neutral-line px-3" />} 70 {user.has_password && <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} placeholder="Mot de passe actuel" autoComplete="current-password" className="h-11 rounded-xl border border-neutral-line px-3" />}
71 <input type="password" value={nw} onChange={(e) => setNw(e.target.value)} placeholder="Nouveau mot de passe (≥ 8)" autoComplete="new-password" className="h-11 rounded-xl border border-neutral-line px-3" /> 71 <input type="password" value={nw} onChange={(e) => setNw(e.target.value)} placeholder="Nouveau mot de passe (≥ 8)" autoComplete="new-password" className="h-11 rounded-xl border border-neutral-line px-3" />
modified frontend/src/features/professor/professor-page.tsx +49 −31
@@ -2,7 +2,7 @@ import { useState, type FormEvent } from 'react';
2 import { fmtDate } from '@/lib/format'; 2 import { fmtDate } from '@/lib/format';
3 import { Link } from 'react-router-dom'; 3 import { Link } from 'react-router-dom';
4 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 4 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5−import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw, Link2, UserPlus, Copy, Check, KeyRound, Shield, ShieldOff, Lock } from 'lucide-react'; 5+import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw, UserPlus, Copy, Check, Shield, ShieldOff, Lock, Mail, Send } from 'lucide-react';
6 import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; 6 import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
7 import { api } from '@/lib/api'; 7 import { api } from '@/lib/api';
8 import { Button } from '@/components/ui/button'; 8 import { Button } from '@/components/ui/button';
@@ -215,74 +215,91 @@ function CourseForm({ c, tools, onSaved, defaultModel }: { c: CourseSettings; to
215 } 215 }
216 216
217 217
218−interface StudentRow { id: string; email: string; role: string; display_name: string | null; created_at: string; last_seen_at: string | null; consent_at: string | null; messages: number; has_password?: boolean } 218+interface StudentRow { id: string; email: string; role: string; display_name: string | null; created_at: string; last_seen_at: string | null; consent_at: string | null; messages: number; has_password?: boolean; invited_at?: string | null }
219−interface StudentsResp { users: StudentRow[]; access_code: string; smtp: boolean } 219+interface StudentsResp { users: StudentRow[]; mail: boolean; mail_from: string; invite_ttl_days: number; pending: number }
220+interface CreateResp { created: string[]; existing: string[]; invalid: string[]; invited: string[]; invite_failed: string[]; mail?: boolean }
220 221
221 function StudentsTab() { 222 function StudentsTab() {
222 const qc = useQueryClient(); 223 const qc = useQueryClient();
223 const { data } = useQuery({ queryKey: ['students'], queryFn: () => api<StudentsResp>('/professor/students') }); 224 const { data } = useQuery({ queryKey: ['students'], queryFn: () => api<StudentsResp>('/professor/students') });
224 const [bulk, setBulk] = useState(''); 225 const [bulk, setBulk] = useState('');
225− const [result, setResult] = useState<{ created: string[]; existing: string[]; invalid: string[] } | null>(null); 226+ const [sendInvites, setSendInvites] = useState(true);
227+ const [result, setResult] = useState<CreateResp | null>(null);
226 const [links, setLinks] = useState<Record<string, string>>({}); 228 const [links, setLinks] = useState<Record<string, string>>({});
227 const [copied, setCopied] = useState<string | null>(null); 229 const [copied, setCopied] = useState<string | null>(null);
228− const [code, setCode] = useState('');
229 const [filter, setFilter] = useState(''); 230 const [filter, setFilter] = useState('');
231+ const [inviteAllMsg, setInviteAllMsg] = useState<string | null>(null);
230 const refresh = () => qc.invalidateQueries({ queryKey: ['students'] }); 232 const refresh = () => qc.invalidateQueries({ queryKey: ['students'] });
231 const create = useMutation({ 233 const create = useMutation({
232− mutationFn: () => api<{ created: string[]; existing: string[]; invalid: string[] }>('/professor/students', { method: 'POST', body: JSON.stringify({ emails: bulk }) }), 234+ mutationFn: () => api<CreateResp>('/professor/students', { method: 'POST', body: JSON.stringify({ emails: bulk, send_invitations: sendInvites }) }),
233 onSuccess: (r) => { setResult(r); setBulk(''); refresh(); }, 235 onSuccess: (r) => { setResult(r); setBulk(''); refresh(); },
234 }); 236 });
237+ const inviteAll = useMutation({
238+ mutationFn: (onlyNever: boolean) => api<{ sent: string[]; failed: string[]; total: number }>('/professor/students/invite-all', { method: 'POST', body: JSON.stringify({ only_never_invited: onlyNever }) }),
239+ onSuccess: (r) => { setInviteAllMsg(`${r.sent.length} invitation(s) envoyée(s)${r.failed.length ? `, ${r.failed.length} échec(s) : ${r.failed.join(', ')}` : ''}.`); refresh(); },
240+ onError: (e) => setInviteAllMsg((e as Error).message),
241+ });
235 const invite = async (u: StudentRow) => { 242 const invite = async (u: StudentRow) => {
236− const r = await api<{ link: string; sent: boolean }>(`/professor/students/${u.id}/invite`, { method: 'POST' }); 243+ try {
237− setLinks((l) => ({ ...l, [u.id]: r.link })); 244+ const r = await api<{ link: string; sent: boolean; expires_days: number }>(`/professor/students/${u.id}/invite`, { method: 'POST' });
238− if (r.sent) alert(`Lien envoyé par courriel à ${u.email}.`); 245+ setLinks((l) => ({ ...l, [u.id]: r.link }));
246+ refresh();
247+ if (r.sent) alert(`Invitation envoyée à ${u.email} (lien valide ${r.expires_days} jours). Le lien est aussi affiché sous le courriel pour le copier.`);
248+ else alert('Courriel non envoyé (envoi non configuré ou refusé). Le lien est affiché sous le courriel : copie-le et transmets-le.');
249+ } catch (e) { alert((e as Error).message); }
239 }; 250 };
240 const copy = async (key: string, text: string) => { 251 const copy = async (key: string, text: string) => {
241 await navigator.clipboard.writeText(text); 252 await navigator.clipboard.writeText(text);
242 setCopied(key); 253 setCopied(key);
243 setTimeout(() => setCopied(null), 1200); 254 setTimeout(() => setCopied(null), 1200);
244 }; 255 };
245− const saveCode = async () => {
246− const r = await api<{ access_code: string }>('/professor/access-code', { method: 'PUT', body: JSON.stringify({ access_code: code }) });
247− setCode('');
248− refresh();
249− alert(`Nouveau code d'accès : ${r.access_code}`);
250− };
251 const users = (data?.users || []).filter((u) => !filter || u.email.includes(filter.toLowerCase()) || (u.display_name || '').toLowerCase().includes(filter.toLowerCase())); 256 const users = (data?.users || []).filter((u) => !filter || u.email.includes(filter.toLowerCase()) || (u.display_name || '').toLowerCase().includes(filter.toLowerCase()));
257+ const neverInvited = (data?.users || []).filter((u) => !u.has_password && !u.invited_at).length;
252 return ( 258 return (
253 <div className="space-y-4"> 259 <div className="space-y-4">
254 <div className="grid md:grid-cols-2 gap-4"> 260 <div className="grid md:grid-cols-2 gap-4">
255− <Card title="Créer des comptes étudiants"> 261+ <Card title="Ajouter des étudiant·es">
256− <p className="text-xs text-neutral-muted mb-2">Un courriel par ligne (ou séparés par des virgules). Format accepté : <code>Prénom Nom &lt;courriel@uqo.ca&gt;</code>. Les comptes créés se connectent avec le code d'accès du cours, un lien personnel, ou un mot de passe que tu définis (icône cadenas).</p> 262+ <p className="text-xs text-neutral-muted mb-2">Un courriel par ligne (ou séparés par des virgules). Format accepté : <code>Prénom Nom &lt;courriel@uqo.ca&gt;</code>. Chaque personne reçoit un courriel « Bienvenue sur UQO-Chat » avec un lien pour choisir son mot de passe (valide {data?.invite_ttl_days ?? 14} jours). Seules les adresses de cette liste peuvent se connecter.</p>
257 <textarea value={bulk} onChange={(e) => setBulk(e.target.value)} rows={6} placeholder={'prenom.nom@uqo.ca\nMarie Tremblay <tremblay.marie@uqo.ca>'} className="w-full rounded-xl border border-neutral-line px-3 py-2 text-sm font-mono" /> 263 <textarea value={bulk} onChange={(e) => setBulk(e.target.value)} rows={6} placeholder={'prenom.nom@uqo.ca\nMarie Tremblay <tremblay.marie@uqo.ca>'} className="w-full rounded-xl border border-neutral-line px-3 py-2 text-sm font-mono" />
264+ <label className="mt-2 flex items-center gap-2 text-sm"><input type="checkbox" checked={sendInvites} onChange={(e) => setSendInvites(e.target.checked)} className="h-4 w-4 accent-uqo-blue" disabled={!data?.mail} /> Envoyer l'invitation par courriel maintenant{!data?.mail && <span className="text-xs text-semantic-error">(envoi non configuré)</span>}</label>
258 <div className="mt-2 flex items-center gap-3"> 265 <div className="mt-2 flex items-center gap-3">
259− <Button onClick={() => create.mutate()} disabled={!bulk.trim() || create.isPending}><UserPlus size={16} /> Créer les comptes</Button> 266+ <Button onClick={() => create.mutate()} disabled={!bulk.trim() || create.isPending}>{create.isPending ? <Spinner className="text-white" /> : <UserPlus size={16} />} Ajouter{sendInvites && data?.mail ? ' et inviter' : ''}</Button>
260 {create.isError && <span className="text-sm text-semantic-error">{(create.error as Error).message}</span>} 267 {create.isError && <span className="text-sm text-semantic-error">{(create.error as Error).message}</span>}
261 </div> 268 </div>
262 {result && ( 269 {result && (
263 <div className="mt-3 text-sm space-y-1"> 270 <div className="mt-3 text-sm space-y-1">
264− <div className="text-uqo-green">✓ {result.created.length} compte(s) créé(s)</div> 271+ <div className="text-uqo-green">✓ {result.created.length} compte(s) créé(s){result.invited.length > 0 && ` · ${result.invited.length} invitation(s) envoyée(s)`}</div>
265− {result.existing.length > 0 && <div className="text-neutral-muted">{result.existing.length} existai(en)t déjà</div>} 272+ {result.invite_failed.length > 0 && <div className="text-semantic-error">Courriel non envoyé à : {result.invite_failed.join(', ')} (icône enveloppe pour réessayer ou copier le lien)</div>}
273+ {result.existing.length > 0 && <div className="text-neutral-muted">{result.existing.length} existai(en)t déjà : {result.existing.join(', ')}</div>}
266 {result.invalid.length > 0 && <div className="text-semantic-error">Invalides : {result.invalid.join(', ')}</div>} 274 {result.invalid.length > 0 && <div className="text-semantic-error">Invalides : {result.invalid.join(', ')}</div>}
267 </div> 275 </div>
268 )} 276 )}
269 </Card> 277 </Card>
270− <Card title="Code d'accès du cours"> 278+ <Card title="Invitations et connexion">
271− <p className="text-xs text-neutral-muted mb-2">Code que les étudiants saisissent avec leur courriel pour se connecter (aucun courriel n'est envoyé). Change-le à chaque trimestre.</p> 279+ <div className="flex items-start gap-3 rounded-xl bg-neutral-surface p-3 text-sm">
272− <div className="flex items-center gap-2 rounded-xl bg-neutral-surface px-3 h-12 font-mono text-lg"><KeyRound size={18} className="text-uqo-blue" /><span className="flex-1 tracking-wider">{data?.access_code || '—'}</span> 280+ <Mail size={18} className="text-uqo-blue shrink-0 mt-0.5" />
273− <button onClick={() => copy('code', data?.access_code || '')} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white" aria-label="Copier">{copied === 'code' ? <Check size={16} className="text-uqo-green" /> : <Copy size={16} />}</button></div> 281+ <div>
274− <div className="mt-3 flex gap-2"> 282+ <div className="font-medium">{data?.mail ? 'Envoi de courriel actif (Resend)' : 'Envoi de courriel non configuré'}</div>
275− <input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Nouveau code (≥ 6 caractères)" className="h-11 flex-1 rounded-xl border border-neutral-line px-3 text-sm" /> 283+ <div className="text-xs text-neutral-muted">{data?.mail ? <>Expéditeur : {data.mail_from}. Les étudiant·es choisissent leur mot de passe via le lien reçu, puis se connectent avec courriel + mot de passe. « Mot de passe oublié » renvoie un lien (valide 60 min).</> : 'Ajoute RESEND_API_KEY au manifeste de déploiement ; en attendant, copie les liens (icône enveloppe).'}</div>
276− <Button variant="secondary" onClick={saveCode} disabled={code.trim().length < 6}>Remplacer</Button> 284+ </div>
285+ </div>
286+ <div className="mt-3 grid grid-cols-2 gap-2 text-center">
287+ <div className="rounded-xl border border-neutral-line p-3"><div className="text-2xl font-bold text-uqo-blue-dark tabular-nums">{data ? data.users.length - data.pending : '…'}</div><div className="text-xs text-neutral-muted">comptes activés</div></div>
288+ <div className="rounded-xl border border-neutral-line p-3"><div className="text-2xl font-bold text-amber-600 tabular-nums">{data?.pending ?? '…'}</div><div className="text-xs text-neutral-muted">en attente de mot de passe</div></div>
289+ </div>
290+ <div className="mt-3 flex flex-wrap gap-2">
291+ <Button variant="secondary" size="sm" disabled={!data?.mail || neverInvited === 0 || inviteAll.isPending} onClick={() => inviteAll.mutate(true)}><Send size={14} /> Inviter les {neverInvited} jamais invité·es</Button>
292+ <Button variant="ghost" size="sm" disabled={!data?.mail || !data?.pending || inviteAll.isPending} onClick={() => confirm(`Renvoyer l'invitation aux ${data?.pending} compte(s) sans mot de passe ?`) && inviteAll.mutate(false)}><RefreshCw size={14} /> Relancer tous les comptes en attente</Button>
277 </div> 293 </div>
278− <p className="text-xs text-neutral-muted mt-3">Lien de connexion : <b>{window.location.origin}/connexion</b>{data?.smtp ? ' · envoi de courriel actif' : ' · envoi de courriel non configuré (liens à copier)'}</p> 294+ {inviteAllMsg && <p className="mt-2 text-sm text-neutral-muted">{inviteAllMsg}</p>}
295+ <p className="text-xs text-neutral-muted mt-3">Page de connexion : <b>{window.location.origin}/connexion</b></p>
279 </Card> 296 </Card>
280 </div> 297 </div>
281 <Card title={`Comptes (${data?.users.length ?? '…'})`}> 298 <Card title={`Comptes (${data?.users.length ?? '…'})`}>
282 <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filtrer…" className="mb-3 h-11 w-full sm:w-72 rounded-xl border border-neutral-line px-3 text-sm" /> 299 <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filtrer…" className="mb-3 h-11 w-full sm:w-72 rounded-xl border border-neutral-line px-3 text-sm" />
283 <div className="overflow-x-auto"> 300 <div className="overflow-x-auto">
284 <table className="w-full text-sm"> 301 <table className="w-full text-sm">
285− <thead><tr className="text-left text-xs text-neutral-muted"><th className="py-1">Courriel</th><th>Nom</th><th>Rôle</th><th>Dernière visite</th><th className="text-right">Messages</th><th></th></tr></thead> 302+ <thead><tr className="text-left text-xs text-neutral-muted"><th className="py-1">Courriel</th><th>Nom</th><th>Rôle</th><th>Statut</th><th>Dernière visite</th><th className="text-right">Messages</th><th></th></tr></thead>
286 <tbody> 303 <tbody>
287 {users.map((u) => ( 304 {users.map((u) => (
288 <tr key={u.id} className="border-t border-neutral-line/70 align-top"> 305 <tr key={u.id} className="border-t border-neutral-line/70 align-top">
@@ -291,11 +308,12 @@ function StudentsTab() {
291 )}</td> 308 )}</td>
292 <td className="py-2 pr-2">{u.display_name || '—'}</td> 309 <td className="py-2 pr-2">{u.display_name || '—'}</td>
293 <td className="py-2 pr-2"><span className={`text-[11px] rounded px-1.5 py-0.5 ${u.role === 'student' ? 'bg-neutral-surface' : 'bg-uqo-blue-light text-uqo-blue-dark'}`}>{u.role === 'student' ? 'étudiant·e' : u.role === 'professor' ? 'professeur' : 'admin'}</span></td> 310 <td className="py-2 pr-2"><span className={`text-[11px] rounded px-1.5 py-0.5 ${u.role === 'student' ? 'bg-neutral-surface' : 'bg-uqo-blue-light text-uqo-blue-dark'}`}>{u.role === 'student' ? 'étudiant·e' : u.role === 'professor' ? 'professeur' : 'admin'}</span></td>
311+ <td className="py-2 pr-2 whitespace-nowrap">{u.has_password ? <span className="inline-flex items-center gap-1 text-[11px] rounded px-1.5 py-0.5 bg-green-50 text-uqo-green"><Check size={12} /> activé</span> : u.invited_at ? <span className="inline-flex items-center gap-1 text-[11px] rounded px-1.5 py-0.5 bg-amber-50 text-amber-700" title={`Invitation envoyée ${fmtDate(u.invited_at)}`}><Mail size={12} /> invité·e {fmtDate(u.invited_at)}</span> : <span className="text-[11px] rounded px-1.5 py-0.5 bg-neutral-surface text-neutral-muted">pas encore invité·e</span>}</td>
294 <td className="py-2 pr-2 text-neutral-muted whitespace-nowrap">{u.last_seen_at ? fmtDate(u.last_seen_at) : 'jamais'}</td> 312 <td className="py-2 pr-2 text-neutral-muted whitespace-nowrap">{u.last_seen_at ? fmtDate(u.last_seen_at) : 'jamais'}</td>
295 <td className="py-2 pr-2 text-right tabular-nums">{u.messages}</td> 313 <td className="py-2 pr-2 text-right tabular-nums">{u.messages}</td>
296 <td className="py-2 text-right whitespace-nowrap"> 314 <td className="py-2 text-right whitespace-nowrap">
297− <button onClick={() => { const pw = prompt(`Nouveau mot de passe pour ${u.email} (≥ 8 caractères) :`); if (pw) api(`/professor/students/${u.id}/password`, { method: 'PUT', body: JSON.stringify({ password: pw }) }).then(() => { alert('Mot de passe défini.'); refresh(); }).catch((e) => alert(e.message)); }} title={u.has_password ? 'Changer le mot de passe' : 'Définir un mot de passe'} className={`h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface ${u.has_password ? 'text-uqo-green' : 'text-neutral-muted'}`} aria-label="Mot de passe"><Lock size={15} /></button> 315+ <button onClick={() => invite(u)} title={u.has_password ? 'Envoyer un lien de réinitialisation du mot de passe' : 'Envoyer / renvoyer l\'invitation (choisir le mot de passe)'} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-uqo-blue" aria-label="Envoyer l'invitation"><Send size={15} /></button>
298− <button onClick={() => invite(u)} title="Lien de connexion personnel (7 jours)" className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-uqo-blue" aria-label="Lien de connexion"><Link2 size={15} /></button> 316+ <button onClick={() => { const pw = prompt(`Définir manuellement un mot de passe pour ${u.email} (≥ 8 caractères) :`); if (pw) api(`/professor/students/${u.id}/password`, { method: 'PUT', body: JSON.stringify({ password: pw }) }).then(() => { alert('Mot de passe défini.'); refresh(); }).catch((e) => alert(e.message)); }} title="Définir un mot de passe manuellement (dépannage)" className={`h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface ${u.has_password ? 'text-uqo-green' : 'text-neutral-muted'}`} aria-label="Mot de passe"><Lock size={15} /></button>
299 {u.role !== 'admin' && ( 317 {u.role !== 'admin' && (
300 <button onClick={() => api(`/professor/students/${u.id}`, { method: 'PATCH', body: JSON.stringify({ role: u.role === 'student' ? 'professor' : 'student' }) }).then(refresh).catch((e) => alert(e.message))} title={u.role === 'student' ? 'Promouvoir professeur' : 'Rétrograder étudiant'} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-neutral-muted" aria-label="Changer le rôle">{u.role === 'student' ? <Shield size={15} /> : <ShieldOff size={15} />}</button> 318 <button onClick={() => api(`/professor/students/${u.id}`, { method: 'PATCH', body: JSON.stringify({ role: u.role === 'student' ? 'professor' : 'student' }) }).then(refresh).catch((e) => alert(e.message))} title={u.role === 'student' ? 'Promouvoir professeur' : 'Rétrograder étudiant'} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-neutral-muted" aria-label="Changer le rôle">{u.role === 'student' ? <Shield size={15} /> : <ShieldOff size={15} />}</button>
301 )} 319 )}
302 320
Menu

Theme