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>
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 <courriel@uqo.ca></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 <courriel@uqo.ca></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 | |