feat(auth): password login (PBKDF2), own password change, professor-set passwords, column migration
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
10 changed files +167 −12
modified
backend/app/api/v1/auth.py
+40 −3
@@ -17,6 +17,7 @@ from app.core.security import ( | ||
| 17 | 17 | decode_token, |
| 18 | 18 | email_allowed, |
| 19 | 19 | get_current_user, |
| 20 | + verify_password, | |
| 20 | 21 | ) |
| 21 | 22 | from app.services import mail, users |
| 22 | 23 | |
@@ -32,6 +33,16 @@ class VerifyReq(BaseModel): | ||
| 32 | 33 | token: str |
| 33 | 34 | |
| 34 | 35 | |
| 36 | +class PasswordLoginReq(BaseModel): | |
| 37 | + email: EmailStr | |
| 38 | + password: str | |
| 39 | + | |
| 40 | + | |
| 41 | +class ChangePasswordReq(BaseModel): | |
| 42 | + current_password: str | None = None | |
| 43 | + new_password: str | |
| 44 | + | |
| 45 | + | |
| 35 | 46 | class PrefsReq(BaseModel): |
| 36 | 47 | tutoiement: bool | None = None |
| 37 | 48 | course: str | None = None |
@@ -90,6 +101,19 @@ async def magic_link(req: MagicLinkReq, request: Request, response: Response, | ||
| 90 | 101 | return out |
| 91 | 102 | |
| 92 | 103 | |
| 104 | +@router.post("/auth/login") | |
| 105 | +async def password_login(req: PasswordLoginReq, request: Request, response: Response, | |
| 106 | + settings: Settings = Depends(get_settings)) -> dict: | |
| 107 | + ip = request.client.host if request.client else "?" | |
| 108 | + limiter.check(f"pwd:{ip}", 30, 3600, "Trop de tentatives. Réessaie plus tard.") | |
| 109 | + limiter.check(f"pwd:{req.email.lower()}", 10, 900, "Trop de tentatives pour ce compte. Patiente 15 minutes.") | |
| 110 | + user = await users.authenticate(req.email, req.password) | |
| 111 | + if not user: | |
| 112 | + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Courriel ou mot de passe invalide.") | |
| 113 | + out = _issue(user.id, user.role, settings, response) | |
| 114 | + return {"mode": "password", **out, "user": _user_dict(user)} | |
| 115 | + | |
| 116 | + | |
| 93 | 117 | @router.post("/auth/verify") |
| 94 | 118 | async def verify(req: VerifyReq, response: Response, |
| 95 | 119 | settings: Settings = Depends(get_settings)) -> dict: |
@@ -123,7 +147,7 @@ async def logout(response: Response) -> dict: | ||
| 123 | 147 | |
| 124 | 148 | @router.get("/auth/config") |
| 125 | 149 | async def auth_config(settings: Settings = Depends(get_settings)) -> dict: |
| 126 | − return {"smtp": settings.smtp_enabled, | |
| 150 | + return {"smtp": settings.smtp_enabled, "password": True, | |
| 127 | 151 | "access_code": bool(await users.effective_access_code(settings)), |
| 128 | 152 | "domains": sorted(settings.allowed_domains), "courses": settings.courses, |
| 129 | 153 | "term": settings.TERM_LABEL} |
@@ -131,8 +155,8 @@ async def auth_config(settings: Settings = Depends(get_settings)) -> dict: | ||
| 131 | 155 | |
| 132 | 156 | def _user_dict(u) -> dict: # noqa: ANN001 |
| 133 | 157 | return {"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name, |
| 134 | − "preferences": u.preferences or {}, "consent_at": u.consent_at.isoformat() | |
| 135 | − if u.consent_at else None} | |
| 158 | + "preferences": u.preferences or {}, "has_password": bool(u.password_hash), | |
| 159 | + "consent_at": u.consent_at.isoformat() if u.consent_at else None} | |
| 136 | 160 | |
| 137 | 161 | |
| 138 | 162 | @router.get("/me") |
@@ -152,6 +176,19 @@ async def patch_prefs(req: PrefsReq, auth: AuthUser = Depends(get_current_user)) | ||
| 152 | 176 | return _user_dict(user) |
| 153 | 177 | |
| 154 | 178 | |
| 179 | +@router.post("/me/password") | |
| 180 | +async def change_password(req: ChangePasswordReq, auth: AuthUser = Depends(get_current_user)) -> dict: | |
| 181 | + user = await users.get_user(auth.id) | |
| 182 | + if not user: | |
| 183 | + 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).") | |
| 186 | + if user.password_hash and not verify_password(req.current_password or "", user.password_hash): | |
| 187 | + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe actuel incorrect.") | |
| 188 | + await users.set_password(auth.id, req.new_password) | |
| 189 | + return {"ok": True} | |
| 190 | + | |
| 191 | + | |
| 155 | 192 | @router.post("/me/consent") |
| 156 | 193 | async def consent(auth: AuthUser = Depends(get_current_user)) -> dict: |
| 157 | 194 | await users.set_consent(auth.id) |
modified
backend/app/api/v1/professor.py
+18 −0
@@ -254,3 +254,21 @@ async def set_access_code(req: AccessCodeReq, _: AuthUser = Depends(require_prof | ||
| 254 | 254 | raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Code trop court (6 caractères minimum).") |
| 255 | 255 | await users.set_setting("access_code", code) |
| 256 | 256 | return {"ok": True, "access_code": code} |
| 257 | + | |
| 258 | + | |
| 259 | +class SetPasswordReq(BaseModel): | |
| 260 | + password: str | |
| 261 | + | |
| 262 | + | |
| 263 | +@router.put("/students/{user_id}/password") | |
| 264 | +async def set_student_password(user_id: str, req: SetPasswordReq, | |
| 265 | + auth: AuthUser = Depends(require_professor)) -> dict: | |
| 266 | + if len(req.password) < 8: | |
| 267 | + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop court (8 caractères minimum).") | |
| 268 | + u = await users.get_user(user_id) | |
| 269 | + if not u: | |
| 270 | + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.") | |
| 271 | + if u.role == "admin" and auth.role != "admin": | |
| 272 | + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé à l'administrateur.") | |
| 273 | + await users.set_password(user_id, req.password) | |
| 274 | + return {"ok": True} | |
modified
backend/app/core/security.py
+20 −0
@@ -2,7 +2,9 @@ | ||
| 2 | 2 | |
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | |
| 5 | +import base64 | |
| 5 | 6 | import hashlib |
| 7 | +import hmac | |
| 6 | 8 | import secrets |
| 7 | 9 | from datetime import UTC, datetime, timedelta |
| 8 | 10 | from typing import Any |
@@ -107,3 +109,21 @@ async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser: | ||
| 107 | 109 | if not user.is_admin: |
| 108 | 110 | raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé à l'administrateur.") |
| 109 | 111 | return user |
| 112 | + | |
| 113 | + | |
| 114 | +# ------------------------------------------------------------------ passwords (PBKDF2-SHA256) | |
| 115 | +def hash_password(password: str, iterations: int = 310_000) -> str: | |
| 116 | + salt = secrets.token_bytes(16) | |
| 117 | + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations) | |
| 118 | + return f"pbkdf2${iterations}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}" | |
| 119 | + | |
| 120 | + | |
| 121 | +def verify_password(password: str, stored: str | None) -> bool: | |
| 122 | + if not stored or not stored.startswith("pbkdf2$"): | |
| 123 | + return False | |
| 124 | + try: | |
| 125 | + _, it, salt_b64, dk_b64 = stored.split("$", 3) | |
| 126 | + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), base64.b64decode(salt_b64), int(it)) | |
| 127 | + return hmac.compare_digest(dk, base64.b64decode(dk_b64)) | |
| 128 | + except (ValueError, TypeError): | |
| 129 | + return False | |
modified
backend/app/db.py
+17 −0
@@ -25,6 +25,23 @@ async def init_db() -> None: | ||
| 25 | 25 | await conn.exec_driver_sql("PRAGMA journal_mode=WAL") |
| 26 | 26 | await conn.exec_driver_sql("PRAGMA foreign_keys=ON") |
| 27 | 27 | await conn.run_sync(Base.metadata.create_all) |
| 28 | + await conn.run_sync(_add_missing_columns) | |
| 29 | + | |
| 30 | + | |
| 31 | +def _add_missing_columns(sync_conn) -> None: # noqa: ANN001 | |
| 32 | + """Poor man's migration: ALTER TABLE ADD COLUMN for new nullable columns (SQLite/Postgres).""" | |
| 33 | + from sqlalchemy import inspect, text | |
| 34 | + | |
| 35 | + insp = inspect(sync_conn) | |
| 36 | + for table in Base.metadata.sorted_tables: | |
| 37 | + if not insp.has_table(table.name): | |
| 38 | + continue | |
| 39 | + existing = {c["name"] for c in insp.get_columns(table.name)} | |
| 40 | + for col in table.columns: | |
| 41 | + if col.name in existing: | |
| 42 | + continue | |
| 43 | + ctype = col.type.compile(sync_conn.dialect) | |
| 44 | + sync_conn.execute(text(f'ALTER TABLE {table.name} ADD COLUMN "{col.name}" {ctype}')) | |
| 28 | 45 | |
| 29 | 46 | |
| 30 | 47 | async def get_session() -> AsyncIterator[AsyncSession]: |
modified
backend/app/models/__init__.py
+1 −0
@@ -39,6 +39,7 @@ class User(Base): | ||
| 39 | 39 | display_name: Mapped[str | None] = mapped_column(String(120)) |
| 40 | 40 | locale: Mapped[str] = mapped_column(String(8), default="fr-CA") |
| 41 | 41 | preferences: Mapped[dict] = mapped_column(JSON, default=dict) |
| 42 | + password_hash: Mapped[str | None] = mapped_column(String(255)) | |
| 42 | 43 | consent_at: Mapped[datetime | None] = mapped_column(DateTime) |
| 43 | 44 | created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) |
| 44 | 45 | last_seen_at: Mapped[datetime | None] = mapped_column(DateTime) |
modified
backend/app/services/users.py
+24 −1
@@ -7,7 +7,7 @@ from datetime import timedelta | ||
| 7 | 7 | from sqlalchemy import delete, func, select |
| 8 | 8 | |
| 9 | 9 | from app.core.config import Settings |
| 10 | −from app.core.security import new_magic_token, role_for_email | |
| 10 | +from app.core.security import hash_password, new_magic_token, role_for_email, verify_password | |
| 11 | 11 | from app.db import SessionLocal |
| 12 | 12 | from app.models import ( |
| 13 | 13 | AppSetting, |
@@ -137,6 +137,7 @@ async def list_users() -> list[dict]: | ||
| 137 | 137 | .where(Message.role == "user").group_by(Conversation.user_id))).all()) |
| 138 | 138 | rows = (await session.execute(select(User).order_by(User.role.desc(), User.email))).scalars() |
| 139 | 139 | return [{"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name, |
| 140 | + "has_password": bool(u.password_hash), | |
| 140 | 141 | "created_at": u.created_at.isoformat(), |
| 141 | 142 | "last_seen_at": u.last_seen_at.isoformat() if u.last_seen_at else None, |
| 142 | 143 | "consent_at": u.consent_at.isoformat() if u.consent_at else None, |
@@ -191,3 +192,25 @@ async def set_setting(key: str, value: str) -> None: | ||
| 191 | 192 | async def effective_access_code(settings: Settings) -> str: |
| 192 | 193 | override = await get_setting("access_code") |
| 193 | 194 | return override if override is not None else settings.ACCESS_CODE |
| 195 | + | |
| 196 | + | |
| 197 | +# ------------------------------------------------------------------ passwords | |
| 198 | +async def set_password(user_id: str, password: str) -> bool: | |
| 199 | + async with SessionLocal() as session: | |
| 200 | + user = await session.get(User, user_id) | |
| 201 | + if not user: | |
| 202 | + return False | |
| 203 | + user.password_hash = hash_password(password) | |
| 204 | + await session.commit() | |
| 205 | + return True | |
| 206 | + | |
| 207 | + | |
| 208 | +async def authenticate(email: str, password: str) -> User | None: | |
| 209 | + async with SessionLocal() as session: | |
| 210 | + user = await session.scalar(select(User).where(User.email == email.lower().strip())) | |
| 211 | + if user and verify_password(password, user.password_hash): | |
| 212 | + user.last_seen_at = utcnow() | |
| 213 | + await session.commit() | |
| 214 | + await session.refresh(user) | |
| 215 | + return user | |
| 216 | + return None | |
modified
frontend/src/features/auth/login-page.tsx
+27 −5
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { useEffect, useState, type FormEvent } from 'react'; |
| 2 | 2 | import { useNavigate, useSearchParams } from 'react-router-dom'; |
| 3 | 3 | import { useQuery } from '@tanstack/react-query'; |
| 4 | −import { KeyRound, Mail, ShieldCheck } from 'lucide-react'; | |
| 4 | +import { KeyRound, Lock, Mail, 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,7 +9,7 @@ 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; domains: string[]; courses: string[]; term: string } | |
| 12 | +interface AuthConfig { smtp: boolean; access_code: boolean; password?: boolean; domains: string[]; courses: string[]; term: string } | |
| 13 | 13 | interface LoginResp { mode: string; token?: string; user?: User; sent?: boolean; dev_link?: string; hint?: string } |
| 14 | 14 | |
| 15 | 15 | export function LoginPage() { |
@@ -19,7 +19,8 @@ export function LoginPage() { | ||
| 19 | 19 | const { data: cfg } = useQuery({ queryKey: ['auth-config'], queryFn: () => api<AuthConfig>('/auth/config') }); |
| 20 | 20 | const [email, setEmail] = useState(''); |
| 21 | 21 | const [code, setCode] = useState(''); |
| 22 | − const [mode, setMode] = useState<'code' | 'link'>('code'); | |
| 22 | + const [mode, setMode] = useState<'code' | 'link' | 'password'>('code'); | |
| 23 | + const [password, setPassword] = useState(''); | |
| 23 | 24 | const [busy, setBusy] = useState(false); |
| 24 | 25 | const [error, setError] = useState<string | null>(null); |
| 25 | 26 | const [info, setInfo] = useState<string | null>(null); |
@@ -46,6 +47,14 @@ export function LoginPage() { | ||
| 46 | 47 | if (!consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; } |
| 47 | 48 | setBusy(true); |
| 48 | 49 | 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 }); | |
| 56 | + return; | |
| 57 | + } | |
| 49 | 58 | const body: Record<string, string> = { email: email.trim() }; |
| 50 | 59 | if (mode === 'code') body.access_code = code.trim(); |
| 51 | 60 | const r = await api<LoginResp>('/auth/magic-link', { method: 'POST', body: JSON.stringify(body) }); |
@@ -88,6 +97,19 @@ export function LoginPage() { | ||
| 88 | 97 | <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" /> |
| 89 | 98 | </span> |
| 90 | 99 | </label> |
| 100 | + <div className="grid grid-cols-2 gap-1 rounded-xl bg-neutral-surface p-1 text-sm" role="tablist"> | |
| 101 | + <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> | |
| 102 | + <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> | |
| 103 | + </div> | |
| 104 | + {mode === 'password' && ( | |
| 105 | + <label className="block"> | |
| 106 | + <span className="text-sm font-medium">Mot de passe</span> | |
| 107 | + <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue"> | |
| 108 | + <Lock size={18} className="ml-3 text-neutral-muted" /> | |
| 109 | + <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" /> | |
| 110 | + </span> | |
| 111 | + </label> | |
| 112 | + )} | |
| 91 | 113 | {mode === 'code' && ( |
| 92 | 114 | <label className="block"> |
| 93 | 115 | <span className="text-sm font-medium">Code d'accès du cours</span> |
@@ -103,8 +125,8 @@ export function LoginPage() { | ||
| 103 | 125 | </label> |
| 104 | 126 | {error && <p className="text-sm text-semantic-error">{error}</p>} |
| 105 | 127 | {info && <p className="text-sm text-uqo-blue-dark bg-uqo-blue-light rounded-lg p-3 break-all">{info}</p>} |
| 106 | − <Button type="submit" size="lg" className="w-full" disabled={busy}>{busy ? <Spinner className="text-white" /> : mode === 'code' ? 'Se connecter' : 'Recevoir un lien de connexion'}</Button> | |
| 107 | − {cfg?.access_code && cfg?.smtp && ( | |
| 128 | + <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> | |
| 129 | + {cfg?.access_code && cfg?.smtp && mode !== 'password' && ( | |
| 108 | 130 | <button type="button" onClick={() => setMode(mode === 'code' ? 'link' : 'code')} className="w-full text-sm text-uqo-blue underline min-h-[44px]"> |
| 109 | 131 | {mode === 'code' ? 'Recevoir plutôt un lien par courriel' : 'Utiliser plutôt le code d\'accès du cours'} |
| 110 | 132 | </button> |
modified
frontend/src/features/auth/settings-dialog.tsx
+15 −0
@@ -11,6 +11,9 @@ export function SettingsDialog() { | ||
| 11 | 11 | const { user, updatePrefs } = useAuth(); |
| 12 | 12 | const nav = useNavigate(); |
| 13 | 13 | const [busy, setBusy] = useState(false); |
| 14 | + const [cur, setCur] = useState(''); | |
| 15 | + const [nw, setNw] = useState(''); | |
| 16 | + const [pwMsg, setPwMsg] = useState<string | null>(null); | |
| 14 | 17 | if (!user) return null; |
| 15 | 18 | const p = user.preferences; |
| 16 | 19 | |
@@ -60,6 +63,18 @@ export function SettingsDialog() { | ||
| 60 | 63 | ))} |
| 61 | 64 | </div> |
| 62 | 65 | </Row> |
| 66 | + <div className="mt-4 rounded-xl border border-neutral-line p-4 text-sm"> | |
| 67 | + <div className="font-medium">{user.has_password ? 'Changer mon mot de passe' : 'Définir un mot de passe'}</div> | |
| 68 | + <p className="text-neutral-muted mt-1">Permet de te connecter avec courriel + mot de passe, sans code du cours.</p> | |
| 69 | + <div className="mt-2 grid gap-2 sm:grid-cols-2"> | |
| 70 | + {user.has_password && <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} placeholder="Mot de passe actuel" autoComplete="current-password" className="h-11 rounded-xl border border-neutral-line px-3" />} | |
| 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" /> | |
| 72 | + </div> | |
| 73 | + <div className="mt-2 flex items-center gap-3"> | |
| 74 | + <Button size="sm" variant="secondary" disabled={nw.length < 8} onClick={() => api('/me/password', { method: 'POST', body: JSON.stringify({ current_password: cur || null, new_password: nw }) }).then(() => { setPwMsg('Mot de passe enregistré.'); setCur(''); setNw(''); useAuth.getState().load(); }).catch((e) => setPwMsg(e.message))}>Enregistrer</Button> | |
| 75 | + {pwMsg && <span className="text-neutral-muted">{pwMsg}</span>} | |
| 76 | + </div> | |
| 77 | + </div> | |
| 63 | 78 | <div className="mt-4 rounded-xl bg-neutral-surface p-4 text-sm"> |
| 64 | 79 | <div className="font-medium">Vie privée (Loi 25)</div> |
| 65 | 80 | <p className="text-neutral-muted mt-1">Tes conversations sont conservées 12 mois puis anonymisées ; les fichiers 7 jours (30 si épinglés). Aucune donnée n'est vendue ni utilisée pour entraîner des modèles. <a href="/confidentialite" className="text-uqo-blue underline">Politique complète</a>.</p> |
modified
frontend/src/features/professor/professor-page.tsx
+4 −3
@@ -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 } from 'lucide-react'; | |
| 5 | +import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw, Link2, UserPlus, Copy, Check, KeyRound, Shield, ShieldOff, Lock } 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,7 +215,7 @@ 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 } | |
| 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 | 219 | interface StudentsResp { users: StudentRow[]; access_code: string; smtp: boolean } |
| 220 | 220 | |
| 221 | 221 | function StudentsTab() { |
@@ -253,7 +253,7 @@ function StudentsTab() { | ||
| 253 | 253 | <div className="space-y-4"> |
| 254 | 254 | <div className="grid md:grid-cols-2 gap-4"> |
| 255 | 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 peuvent se connecter avec le code d'accès du cours, ou via un lien personnel.</p> | |
| 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> | |
| 257 | 257 | <textarea value={bulk} onChange={(e) => setBulk(e.target.value)} rows={6} placeholder={'prenom.nom@uqo.ca\nMarie Tremblay <tremblay.marie@uqo.ca>'} className="w-full rounded-xl border border-neutral-line px-3 py-2 text-sm font-mono" /> |
| 258 | 258 | <div className="mt-2 flex items-center gap-3"> |
| 259 | 259 | <Button onClick={() => create.mutate()} disabled={!bulk.trim() || create.isPending}><UserPlus size={16} /> Créer les comptes</Button> |
@@ -294,6 +294,7 @@ function StudentsTab() { | ||
| 294 | 294 | <td className="py-2 pr-2 text-neutral-muted whitespace-nowrap">{u.last_seen_at ? fmtDate(u.last_seen_at) : 'jamais'}</td> |
| 295 | 295 | <td className="py-2 pr-2 text-right tabular-nums">{u.messages}</td> |
| 296 | 296 | <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> | |
| 297 | 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> |
| 298 | 299 | {u.role !== 'admin' && ( |
| 299 | 300 | <button onClick={() => api(`/professor/students/${u.id}`, { method: 'PATCH', body: JSON.stringify({ role: u.role === 'student' ? 'professor' : 'student' }) }).then(refresh).catch((e) => alert(e.message))} title={u.role === 'student' ? 'Promouvoir professeur' : 'Rétrograder étudiant'} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-neutral-muted" aria-label="Changer le rôle">{u.role === 'student' ? <Shield size={15} /> : <ShieldOff size={15} />}</button> |
modified
frontend/src/lib/types.ts
+1 −0
@@ -7,6 +7,7 @@ export interface User { | ||
| 7 | 7 | display_name: string | null; |
| 8 | 8 | preferences: { tutoiement?: boolean; course?: string; deep?: boolean; locale?: string; font_scale?: number }; |
| 9 | 9 | consent_at: string | null; |
| 10 | + has_password?: boolean; | |
| 10 | 11 | } |
| 11 | 12 | |
| 12 | 13 | export interface Course { |
| 13 | 14 | |