SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%

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 18 days ago (Sep 6, 2026) parent 1b12758

16 changed files +865 −216

modified .env.example +6 −2
@@ -31,8 +31,12 @@ ALLOWED_EMAIL_DOMAINS=uqo.ca
31 31 INVITED_EMAILS=
32 32 PROFESSOR_EMAILS=prof@uqo.ca
33 33 ADMIN_EMAILS=
34 −# Code d'accès du cours (connexion sans SMTP) — laisser vide pour imposer le lien magique
35 −ACCESS_CODE=
34 +# Courriels (invitation « choisis ton mot de passe », mot de passe oublié) : Resend d'abord, SMTP en repli
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 40 SMTP_HOST=
37 41 SMTP_PORT=587
38 42 SMTP_USER=
modified CLAUDE.md +3 −3
@@ -14,14 +14,14 @@ Voir `README.md` pour le démarrage et le déploiement.
14 14 7. Mobile-first (375 px) ; QA Playwright : `python3 /tmp/uqo-qa/qa.py` (captures 375/1440, détection d'overflow).
15 15
16 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.
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.
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 (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 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 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 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 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 26 ## À faire (roadmap spec)
27 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 26 ## Démarrage local
27 27
28 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 30 make setup # venvs uv (backend, sandbox) + npm install
31 31 make sandbox & # :8191
32 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 35 make test && make lint
36 36 ```
37 37
38 −Connexion locale : courriel autorisé (`ALLOWED_EMAIL_DOMAINS` / `INVITED_EMAILS`) + `ACCESS_CODE`,
39 −ou lien magique si `SMTP_HOST` est configuré (en dev le lien est renvoyé dans la réponse).
38 +Connexion : le professeur inscrit les courriels des étudiant·es (onglet **Étudiants**) ; chacun·e reçoit
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 46 ## Déploiement
42 47
@@ -63,7 +68,7 @@ pm2 restart uqo-chat-api
63 68 |---|---|---|
64 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 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 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 73 | Alembic | `create_all` au démarrage | Une migration initiale sera ajoutée au premier changement de schéma |
69 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 8 from __future__ import annotations
4 9
5 −import hmac
6 10 from datetime import timedelta
7 11
8 12 from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
@@ -19,14 +23,11 @@ from app.core.security import (
19 23 get_current_user,
20 24 verify_password,
21 25 )
22 −from app.services import mail, users
26 +from app.services import invites, users
23 27
24 28 router = APIRouter(tags=["auth"])
25 29
26 −
27 −class MagicLinkReq(BaseModel):
28 − email: EmailStr
29 − access_code: str | None = None
30 +MIN_PASSWORD = 8
30 31
31 32
32 33 class VerifyReq(BaseModel):
@@ -38,6 +39,15 @@ class PasswordLoginReq(BaseModel):
38 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 51 class ChangePasswordReq(BaseModel):
42 52 current_password: str | None = None
43 53 new_password: str
@@ -70,37 +80,15 @@ def _issue(user_id: str, role: str, settings: Settings, resp: Response) -> dict:
70 80 return {"token": token, "role": role}
71 81
72 82
73 −@router.post("/auth/magic-link")
74 −async def magic_link(req: MagicLinkReq, request: Request, response: Response,
75 − settings: Settings = Depends(get_settings)) -> dict:
76 − ip = request.client.host if request.client else "?"
77 − limiter.check(f"login:{ip}", 20, 3600, "Trop de tentatives. Réessaie plus tard.")
78 − email = req.email.lower()
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
83 +def _check_password_strength(password: str) -> None:
84 + if len(password) < MIN_PASSWORD:
85 + raise HTTPException(status.HTTP_400_BAD_REQUEST,
86 + detail=f"Mot de passe trop court ({MIN_PASSWORD} caractères minimum).")
87 + if len(password) > 128:
88 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop long (128 maximum).")
102 89
103 90
91 +# ------------------------------------------------------------------ sign-in
104 92 @router.post("/auth/login")
105 93 async def password_login(req: PasswordLoginReq, request: Request, response: Response,
106 94 settings: Settings = Depends(get_settings)) -> dict:
@@ -109,15 +97,82 @@ async def password_login(req: PasswordLoginReq, request: Request, response: Resp
109 97 limiter.check(f"pwd:{req.email.lower()}", 10, 900, "Trop de tentatives pour ce compte. Patiente 15 minutes.")
110 98 user = await users.authenticate(req.email, req.password)
111 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 105 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Courriel ou mot de passe invalide.")
113 106 out = _issue(user.id, user.role, settings, response)
114 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 171 @router.post("/auth/verify")
118 172 async def verify(req: VerifyReq, response: Response,
119 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 176 if not email:
122 177 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré.")
123 178 user = await users.get_or_create_user(email, settings)
@@ -147,8 +202,8 @@ async def logout(response: Response) -> dict:
147 202
148 203 @router.get("/auth/config")
149 204 async def auth_config(settings: Settings = Depends(get_settings)) -> dict:
150 − return {"smtp": settings.smtp_enabled, "password": True,
151 − "access_code": bool(await users.effective_access_code(settings)),
205 + return {"password": True, "mail": settings.mail_enabled,
206 + "min_password": MIN_PASSWORD,
152 207 "domains": sorted(settings.allowed_domains), "courses": settings.courses,
153 208 "term": settings.TERM_LABEL}
154 209
@@ -159,6 +214,7 @@ def _user_dict(u) -> dict: # noqa: ANN001
159 214 "consent_at": u.consent_at.isoformat() if u.consent_at else None}
160 215
161 216
217 +# ------------------------------------------------------------------ me
162 218 @router.get("/me")
163 219 async def me(auth: AuthUser = Depends(get_current_user)) -> dict:
164 220 user = await users.get_user(auth.id)
@@ -181,8 +237,7 @@ async def change_password(req: ChangePasswordReq, auth: AuthUser = Depends(get_c
181 237 user = await users.get_user(auth.id)
182 238 if not user:
183 239 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")
184 − if len(req.new_password) < 8:
185 − raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop court (8 caractères minimum).")
240 + _check_password_strength(req.new_password)
186 241 if user.password_hash and not verify_password(req.current_password or "", user.password_hash):
187 242 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe actuel incorrect.")
188 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 18 from app.models import CourseChunk, CourseDocument
19 19 from app.rag import retriever
20 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 22 from app.services import courses as course_service
23 23 from app.tools.all import registry
24 24
@@ -167,19 +167,23 @@ async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) ->
167 167 @router.get("/students")
168 168 async def list_students(_: AuthUser = Depends(require_professor),
169 169 settings: Settings = Depends(get_settings)) -> dict:
170 − return {"users": await users.list_users(),
171 − "access_code": await users.effective_access_code(settings),
172 − "smtp": settings.smtp_enabled}
170 + rows = await users.list_users()
171 + return {"users": rows, "mail": settings.mail_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 177 class CreateStudentsReq(BaseModel):
176 178 emails: list[str] | str
177 179 role: str = "student"
180 + send_invitations: bool = True
178 181
179 182
180 183 @router.post("/students", status_code=201)
181 184 async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_professor),
182 185 settings: Settings = Depends(get_settings)) -> dict:
186 + """Register addresses; each new account receives a 'choose your password' e-mail."""
183 187 raw = req.emails if isinstance(req.emails, list) else re.split(r"[\n;,]+", req.emails)
184 188 names: dict[str, str] = {}
185 189 emails: list[str] = []
@@ -195,7 +199,30 @@ async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_
195 199 emails.extend(t for t in re.split(r"\s+", item) if t)
196 200 if req.role not in {"student", "professor"}:
197 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 228 class RoleReq(BaseModel):
@@ -229,31 +256,13 @@ async def delete_student(user_id: str, auth: AuthUser = Depends(require_professo
229 256 @router.post("/students/{user_id}/invite")
230 257 async def invite_student(user_id: str, _: AuthUser = Depends(require_professor),
231 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 260 u = await users.get_user(user_id)
234 261 if not u:
235 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)
237 − link = f"{settings.APP_URL}/connexion?token={token}"
238 − sent = False
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}
263 + outcome = await invites.invite_users([u], settings)
264 + return {"link": outcome["links"][u.id], "sent": u.email in outcome["sent"],
265 + "expires_days": settings.INVITE_TTL_DAYS}
257 266
258 267
259 268 class SetPasswordReq(BaseModel):
modified backend/app/core/config.py +15 −1
@@ -71,7 +71,13 @@ class Settings(BaseSettings):
71 71 INVITED_EMAILS: str = ""
72 72 PROFESSOR_EMAILS: str = ""
73 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 81 SMTP_HOST: str = ""
76 82 SMTP_PORT: int = 587
77 83 SMTP_USER: str = ""
@@ -133,6 +139,14 @@ class Settings(BaseSettings):
133 139 def smtp_enabled(self) -> bool:
134 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 150 @property
137 151 def is_dev(self) -> bool:
138 152 return self.APP_ENV != "production"
modified backend/app/models/__init__.py +4 −0
@@ -44,14 +44,18 @@ class User(Base):
44 44 created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
45 45 last_seen_at: Mapped[datetime | None] = mapped_column(DateTime)
46 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 50 class MagicLink(Base):
51 + """Single-use e-mailed tokens: login link, invitation (choose password), password reset."""
52 +
50 53 __tablename__ = "magic_links"
51 54 token: Mapped[str] = mapped_column(String(64), primary_key=True)
52 55 email: Mapped[str] = mapped_column(String(255), index=True)
53 56 expires_at: Mapped[datetime] = mapped_column(DateTime)
54 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 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 8 from __future__ import annotations
4 9
10 +import html
11 +from dataclasses import dataclass
5 12 from email.message import EmailMessage
6 13
7 −import aiosmtplib
14 +import httpx
8 15
9 16 from app.core.config import Settings
10 17 from app.core.logging import get_logger
11 18
12 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 133 msg = EmailMessage()
20 134 msg["From"] = settings.SMTP_FROM
21 − msg["To"] = to
22 − msg["Subject"] = "Connexion à UQO-Chat"
23 − msg.set_content(
24 − "Bonjour,\n\nVoici ton lien de connexion à UQO-Chat (valide 20 minutes) :\n\n"
25 − f"{link}\n\nSi tu n'as pas demandé ce lien, ignore ce courriel.\n\n— UQO-Chat, "
26 − "tuteur IA IMM1003 · IMM1033")
135 + msg["To"] = m.to
136 + msg["Subject"] = m.subject
137 + msg.set_content(m.text)
138 + msg.add_alternative(m.html, subtype="html")
27 139 await aiosmtplib.send(
28 140 msg, hostname=settings.SMTP_HOST, port=settings.SMTP_PORT,
29 141 username=settings.SMTP_USER or None,
30 142 password=settings.SMTP_PASSWORD.get_secret_value() or None,
31 143 start_tls=settings.SMTP_PORT == 587)
32 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 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 83 token = new_magic_token()
83 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 86 expires_at=utcnow() + timedelta(minutes=ttl_minutes)))
86 87 await session.commit()
87 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 103 async with SessionLocal() as session:
92 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 108 return None
95 109 link.used_at = utcnow()
96 110 await session.commit()
97 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 152 async def set_role(email: str, role: str) -> bool:
101 153 async with SessionLocal() as session:
102 154 user = await session.scalar(select(User).where(User.email == email.lower().strip()))
@@ -138,6 +190,7 @@ async def list_users() -> list[dict]:
138 190 rows = (await session.execute(select(User).order_by(User.role.desc(), User.email))).scalars()
139 191 return [{"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name,
140 192 "has_password": bool(u.password_hash),
193 + "invited_at": u.invited_at.isoformat() if u.invited_at else None,
141 194 "created_at": u.created_at.isoformat(),
142 195 "last_seen_at": u.last_seen_at.isoformat() if u.last_seen_at else None,
143 196 "consent_at": u.consent_at.isoformat() if u.consent_at else None,
@@ -147,6 +200,7 @@ async def list_users() -> list[dict]:
147 200 async def create_users(emails: list[str], settings: Settings, role: str = "student",
148 201 display_names: dict[str, str] | None = None) -> dict:
149 202 created, existing, invalid = [], [], []
203 + created_ids: list[str] = []
150 204 async with SessionLocal() as session:
151 205 for raw in emails:
152 206 email = raw.strip().lower()
@@ -154,18 +208,23 @@ async def create_users(emails: list[str], settings: Settings, role: str = "stude
154 208 if raw.strip():
155 209 invalid.append(raw.strip())
156 210 continue
211 + if email in created:
212 + continue
157 213 user = await session.scalar(select(User).where(User.email == email))
158 214 if user:
159 215 existing.append(email)
160 216 continue
161 217 r = role_for_email(email, settings)
162 − session.add(User(email=email, role=r if r != "student" else role,
163 − display_name=(display_names or {}).get(email) or email.split("@")[0],
164 − preferences={"tutoiement": True, "course": settings.courses[0],
165 − "deep": False, "locale": "fr-CA"}))
218 + u = User(email=email, role=r if r != "student" else role,
219 + display_name=(display_names or {}).get(email) or email.split("@")[0],
220 + preferences={"tutoiement": True, "course": settings.courses[0],
221 + "deep": False, "locale": "fr-CA"})
222 + session.add(u)
166 223 created.append(email)
224 + await session.flush()
225 + created_ids.append(u.id)
167 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 230 async def email_registered(email: str) -> bool:
@@ -189,11 +248,6 @@ async def set_setting(key: str, value: str) -> None:
189 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 251 # ------------------------------------------------------------------ passwords
198 252 async def set_password(user_id: str, password: str) -> bool:
199 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 6 import { ChatView } from '@/components/chat/chat-view';
7 7 import { LoginPage } from '@/features/auth/login-page';
8 8 import { PrivacyPage } from '@/features/auth/privacy-page';
9 +import { SetPasswordPage } from '@/features/auth/set-password-page';
9 10 import { ProfessorPage } from '@/features/professor/professor-page';
10 11 import { AdminCostsPage } from '@/features/professor/admin-costs-page';
11 12 import { Spinner } from '@/components/ui/spinner';
@@ -32,6 +33,7 @@ export function AppRouter() {
32 33 return (
33 34 <Routes>
34 35 <Route path="/connexion" element={<LoginPage />} />
36 + <Route path="/mot-de-passe" element={<SetPasswordPage />} />
35 37 <Route path="/confidentialite" element={<PrivacyPage />} />
36 38 <Route path="/professeur" element={<Guard role="professor"><ProfessorPage /></Guard>} />
37 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 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 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 5 import { api, setToken } from '@/lib/api';
6 6 import type { User } from '@/lib/types';
7 7 import { useAuth } from '@/stores/auth';
@@ -9,28 +9,25 @@ import { Logo } from '@/components/ui/logo';
9 9 import { Button } from '@/components/ui/button';
10 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 }
13 −interface LoginResp { mode: string; token?: string; user?: User; sent?: boolean; dev_link?: string; hint?: 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 }
14 +interface ForgotResp { sent: boolean; first_time: boolean; ttl_minutes: number; dev_link?: string; hint?: string }
14 15
15 16 export function LoginPage() {
16 17 const nav = useNavigate();
17 18 const [params] = useSearchParams();
18 19 const setUser = useAuth((s) => s.setUser);
19 20 const { data: cfg } = useQuery({ queryKey: ['auth-config'], queryFn: () => api<AuthConfig>('/auth/config') });
20 − const [email, setEmail] = useState('');
21 − const [code, setCode] = useState('');
22 − const [mode, setMode] = useState<'code' | 'link' | 'password'>('code');
21 + const [email, setEmail] = useState(params.get('email') || '');
23 22 const [password, setPassword] = useState('');
23 + const [mode, setMode] = useState<'password' | 'forgot'>(params.get('mode') === 'forgot' ? 'forgot' : 'password');
24 24 const [busy, setBusy] = useState(false);
25 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 27 const [consent, setConsent] = useState(false);
28 28 const token = params.get('token');
29 29
30 − useEffect(() => {
31 − if (cfg) setMode(cfg.access_code ? 'code' : 'link');
32 − }, [cfg]);
33 −
30 + // Legacy direct sign-in links (/connexion?token=…) sent before the password flow.
34 31 useEffect(() => {
35 32 if (!token) return;
36 33 setBusy(true);
@@ -43,34 +40,20 @@ export function LoginPage() {
43 40 const submit = async (e: FormEvent) => {
44 41 e.preventDefault();
45 42 setError(null);
46 − setInfo(null);
47 − if (!consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; }
43 + setSent(null);
48 44 setBusy(true);
49 45 try {
50 − if (mode === 'password') {
51 − const r = await api<LoginResp>('/auth/login', { method: 'POST', body: JSON.stringify({ email: email.trim(), password }) });
52 − if (r.token) setToken(r.token);
53 − if (r.user) setUser(r.user);
54 − await api('/me/consent', { method: 'POST' }).catch(() => undefined);
55 − nav('/', { replace: true });
46 + if (mode === 'forgot') {
47 + const r = await api<ForgotResp>('/auth/forgot', { method: 'POST', body: JSON.stringify({ email: email.trim() }) });
48 + setSent(r);
56 49 return;
57 50 }
58 − const body: Record<string, string> = { email: email.trim() };
59 − if (mode === 'code') body.access_code = code.trim();
60 − const r = await api<LoginResp>('/auth/magic-link', { method: 'POST', body: JSON.stringify(body) });
61 − if (r.token && r.user) {
62 − setToken(r.token);
63 − setUser(r.user);
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 − }
51 + if (!consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; }
52 + const r = await api<LoginResp>('/auth/login', { method: 'POST', body: JSON.stringify({ email: email.trim(), password }) });
53 + if (r.token) setToken(r.token);
54 + if (r.user) setUser(r.user);
55 + await api('/me/consent', { method: 'POST' }).catch(() => undefined);
56 + nav('/', { replace: true });
74 57 } catch (e) {
75 58 setError(e instanceof Error ? e.message : 'Connexion impossible.');
76 59 } finally {
@@ -89,53 +72,59 @@ export function LoginPage() {
89 72 </div>
90 73 <div className="flex-1 px-4 -mt-9 pb-[calc(24px+var(--safe-bottom))]">
91 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>
93 − <h1 className="text-xl font-bold text-uqo-blue-dark">Connexion</h1>
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>
95 − </div>
96 − {token && busy && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>}
97 − <label className="block">
98 − <span className="text-sm font-medium">Courriel UQO</span>
99 − <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">
100 − <Mail size={18} className="ml-3 text-neutral-muted" />
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" />
102 − </span>
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>
75 + {mode === 'password' ? (
76 + <div>
77 + <h1 className="text-xl font-bold text-uqo-blue-dark">Connexion</h1>
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>
79 + </div>
80 + ) : (
81 + <div>
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>
83 + <h1 className="text-xl font-bold text-uqo-blue-dark mt-1">Première connexion ou mot de passe oublié</h1>
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>
85 + </div>
125 86 )}
126 − <label className="flex items-start gap-2 text-sm">
127 − <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} className="mt-1 h-4 w-4 accent-uqo-blue" />
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>
129 − </label>
130 − {error && <p className="text-sm text-semantic-error">{error}</p>}
131 − {info && <p className="text-sm text-uqo-blue-dark bg-uqo-blue-light rounded-lg p-3 break-all">{info}</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>
133 − {cfg?.access_code && cfg?.smtp && mode !== 'password' && (
134 − <button type="button" onClick={() => setMode(mode === 'code' ? 'link' : 'code')} className="w-full text-sm text-uqo-blue underline min-h-[44px]">
135 − {mode === 'code' ? 'Recevoir plutôt un lien par courriel' : 'Utiliser plutôt le code d\'accès du cours'}
136 − </button>
87 + {token && busy && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>}
88 + {sent ? (
89 + <div className="rounded-2xl bg-uqo-blue-light p-4 text-sm text-uqo-blue-dark space-y-2">
90 + <div className="flex items-center gap-2 font-semibold"><MailCheck size={18} /> {sent.sent ? 'Courriel envoyé' : 'Courriel non envoyé'}</div>
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>}
92 + {sent.hint && <p>{sent.hint}</p>}
93 + {sent.dev_link && <p className="break-all">Mode développement — lien : <a className="underline" href={sent.dev_link}>{sent.dev_link}</a></p>}
94 + <button type="button" onClick={() => { setMode('password'); setSent(null); }} className="text-uqo-blue underline min-h-[32px]">Retour à la connexion</button>
95 + </div>
96 + ) : (
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 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 128 </form>
140 129 </div>
141 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 65 </Row>
66 66 <div className="mt-4 rounded-xl border border-neutral-line p-4 text-sm">
67 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 69 <div className="mt-2 grid gap-2 sm:grid-cols-2">
70 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 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 2 import { fmtDate } from '@/lib/format';
3 3 import { Link } from 'react-router-dom';
4 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 6 import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
7 7 import { api } from '@/lib/api';
8 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 }
219 −interface StudentsResp { users: StudentRow[]; access_code: string; smtp: 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[]; 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 222 function StudentsTab() {
222 223 const qc = useQueryClient();
223 224 const { data } = useQuery({ queryKey: ['students'], queryFn: () => api<StudentsResp>('/professor/students') });
224 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 228 const [links, setLinks] = useState<Record<string, string>>({});
227 229 const [copied, setCopied] = useState<string | null>(null);
228 − const [code, setCode] = useState('');
229 230 const [filter, setFilter] = useState('');
231 + const [inviteAllMsg, setInviteAllMsg] = useState<string | null>(null);
230 232 const refresh = () => qc.invalidateQueries({ queryKey: ['students'] });
231 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 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 242 const invite = async (u: StudentRow) => {
236 − const r = await api<{ link: string; sent: boolean }>(`/professor/students/${u.id}/invite`, { method: 'POST' });
237 − setLinks((l) => ({ ...l, [u.id]: r.link }));
238 − if (r.sent) alert(`Lien envoyé par courriel à ${u.email}.`);
243 + try {
244 + const r = await api<{ link: string; sent: boolean; expires_days: number }>(`/professor/students/${u.id}/invite`, { method: 'POST' });
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 251 const copy = async (key: string, text: string) => {
241 252 await navigator.clipboard.writeText(text);
242 253 setCopied(key);
243 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 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 258 return (
253 259 <div className="space-y-4">
254 260 <div className="grid md:grid-cols-2 gap-4">
255 − <Card title="Créer des comptes étudiants">
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>
261 + <Card title="Ajouter des étudiant·es">
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 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 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 267 {create.isError && <span className="text-sm text-semantic-error">{(create.error as Error).message}</span>}
261 268 </div>
262 269 {result && (
263 270 <div className="mt-3 text-sm space-y-1">
264 − <div className="text-uqo-green">✓ {result.created.length} compte(s) créé(s)</div>
265 − {result.existing.length > 0 && <div className="text-neutral-muted">{result.existing.length} existai(en)t déjà</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>
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 274 {result.invalid.length > 0 && <div className="text-semantic-error">Invalides : {result.invalid.join(', ')}</div>}
267 275 </div>
268 276 )}
269 277 </Card>
270 − <Card title="Code d'accès du cours">
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>
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>
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>
274 − <div className="mt-3 flex gap-2">
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" />
276 − <Button variant="secondary" onClick={saveCode} disabled={code.trim().length < 6}>Remplacer</Button>
278 + <Card title="Invitations et connexion">
279 + <div className="flex items-start gap-3 rounded-xl bg-neutral-surface p-3 text-sm">
280 + <Mail size={18} className="text-uqo-blue shrink-0 mt-0.5" />
281 + <div>
282 + <div className="font-medium">{data?.mail ? 'Envoi de courriel actif (Resend)' : 'Envoi de courriel non configuré'}</div>
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>
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 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 296 </Card>
280 297 </div>
281 298 <Card title={`Comptes (${data?.users.length ?? '…'})`}>
282 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 300 <div className="overflow-x-auto">
284 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 303 <tbody>
287 304 {users.map((u) => (
288 305 <tr key={u.id} className="border-t border-neutral-line/70 align-top">
@@ -291,11 +308,12 @@ function StudentsTab() {
291 308 )}</td>
292 309 <td className="py-2 pr-2">{u.display_name || '—'}</td>
293 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 312 <td className="py-2 pr-2 text-neutral-muted whitespace-nowrap">{u.last_seen_at ? fmtDate(u.last_seen_at) : 'jamais'}</td>
295 313 <td className="py-2 pr-2 text-right tabular-nums">{u.messages}</td>
296 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>
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>
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>
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 317 {u.role !== 'admin' && (
300 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