From 43a219615007208da971939040973f6f88c2211e Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Sun, 10 May 2026 19:52:10 +0200 Subject: [PATCH] =?UTF-8?q?auth:=20flow=20email=20pour=20mdp=20+=20page=20?= =?UTF-8?q?profil=20+=20restriction=20d'acc=C3=A8s=20par=20classe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - création de compte par admin envoie un email avec lien de définition (7j), bouton "Reset mdp" pour renvoyer un lien (24h). Plus aucun admin ne peut modifier directement le mdp d'un user (tout passe par les liens email). - nouvelle page /password-set publique (validation token, formulaire, hash bcrypt) au style aligné sur /login, avec emails multipart texte+HTML. - nouvelle page /profile (changement mdp avec ancien, reset 2FA, avatar, infos), accessible via dropdown sur le widget user en bas de sidebar. - restriction d'accès par utilisateur : champ allowed_classes dans auth.yaml, multi-select dans la page Users, filtrage cross-page (KPIs, sanctions, classes, apprentis, navigations cross-page, génération PDF avis). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + data/auth.yaml | 7 +- data/email_templates/reset.html | 61 +++ data/email_templates/reset.txt | 14 + data/email_templates/welcome.html | 68 +++ data/email_templates/welcome.txt | 16 + data/settings.json | 3 +- eptm_dashboard/eptm_dashboard.py | 5 + eptm_dashboard/pages/accueil.py | 58 +- eptm_dashboard/pages/classe.py | 8 + eptm_dashboard/pages/fiche.py | 21 +- eptm_dashboard/pages/params.py | 49 ++ eptm_dashboard/pages/password_set.py | 303 +++++++++++ eptm_dashboard/pages/profile.py | 468 ++++++++++++++++ eptm_dashboard/pages/users.py | 785 +++++++++++++++++---------- eptm_dashboard/sidebar.py | 93 +++- src/email_sender.py | 15 +- src/password_emails.py | 128 +++++ src/password_tokens.py | 126 +++++ src/user_access.py | 60 ++ 20 files changed, 1968 insertions(+), 321 deletions(-) create mode 100644 data/email_templates/reset.html create mode 100644 data/email_templates/reset.txt create mode 100644 data/email_templates/welcome.html create mode 100644 data/email_templates/welcome.txt create mode 100644 eptm_dashboard/pages/password_set.py create mode 100644 eptm_dashboard/pages/profile.py create mode 100644 src/password_emails.py create mode 100644 src/password_tokens.py create mode 100644 src/user_access.py diff --git a/.gitignore b/.gitignore index a1635f6..c8552ac 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ data/pdfs/ data/sync_*.json data/debug_*.png data/*.bak.* +data/password_tokens.json # Logs cron (runtime) logs/ diff --git a/data/auth.yaml b/data/auth.yaml index f9cec36..dc531db 100644 --- a/data/auth.yaml +++ b/data/auth.yaml @@ -13,8 +13,11 @@ credentials: smtp_password: 17acdfd671d8ab totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY test: + allowed_classes: + - AUTOMAT 1 + - EM-AU 1 email: julien@balet-vs.ch name: test - password: $2b$12$nYZqG/bStQwweDjvR/8RNOqP6AnUDh1Dictx3BCZ2RalIyWDbre42 + password: $2b$12$dAvkehPvcU5zokLhUzjswu7APcRi1e4C1IeR6Gc7/51wh9vGTl4MS role: user - totp_secret: TCH5IQCRIAVPZEFFUABEVXUCV7TOL5XP + totp_secret: SVX56DCKFEOYRDPLUTML2YN6RUCME3AA diff --git a/data/email_templates/reset.html b/data/email_templates/reset.html new file mode 100644 index 0000000..38c6f39 --- /dev/null +++ b/data/email_templates/reset.html @@ -0,0 +1,61 @@ + + + + +Réinitialisation de votre mot de passe + + + + + + + + + + + + + + + + + + +
+

Bonjour {name},

+

Une demande de réinitialisation de mot de passe a été effectuée pour votre compte EPTM Dashboard.

+
+ + + + +
+ Identifiant : {username} +
+
+

+ Pour définir un nouveau mot de passe : +

+

+ + Réinitialiser mon mot de passe + +

+

+ Ou copiez ce lien dans votre navigateur :
+ {link} +

+
+

+ Lien valable jusqu'au {expiry} ({ttl_human}). +

+
+

+ Si vous n'êtes pas à l'origine de cette demande, ignorez cet email — votre mot de passe actuel reste inchangé. +

+

+ Cordialement,
L'équipe EPTM +

+
+ + diff --git a/data/email_templates/reset.txt b/data/email_templates/reset.txt new file mode 100644 index 0000000..7beb0dc --- /dev/null +++ b/data/email_templates/reset.txt @@ -0,0 +1,14 @@ +Bonjour {name}, + +Une demande de réinitialisation de mot de passe a été effectuée pour votre compte EPTM Dashboard ({username}). + +Pour définir un nouveau mot de passe, cliquez sur le lien ci-dessous : + +{link} + +Ce lien est valable jusqu'au {expiry} ({ttl_human}). + +Si vous n'êtes pas à l'origine de cette demande, ignorez cet email — votre mot de passe actuel reste inchangé. + +Cordialement, +L'équipe EPTM diff --git a/data/email_templates/welcome.html b/data/email_templates/welcome.html new file mode 100644 index 0000000..f404c8e --- /dev/null +++ b/data/email_templates/welcome.html @@ -0,0 +1,68 @@ + + + + +Activation de votre compte EPTM + + + + + + + + + + + + + + + + + + + + + +
+

Bienvenue {name},

+

Un compte a été créé pour vous sur EPTM Dashboard.

+
+ + + + +
+ Identifiant : {username} +
+
+

+ Pour activer votre compte, définissez votre mot de passe : +

+

+ + Définir mon mot de passe + +

+

+ Ou copiez ce lien dans votre navigateur :
+ {link} +

+
+

+ Lien valable jusqu'au {expiry} ({ttl_human}). +

+
+

+ 🔐 À votre prochaine connexion, l'application vous demandera de configurer un second facteur d'authentification (TOTP) via une application comme Google Authenticator, Authy, 1Password ou Microsoft Authenticator. +

+
+

+ Si vous n'êtes pas à l'origine de cette demande, ignorez cet email. +

+

+ Cordialement,
L'équipe EPTM +

+
+ + diff --git a/data/email_templates/welcome.txt b/data/email_templates/welcome.txt new file mode 100644 index 0000000..0fc3b02 --- /dev/null +++ b/data/email_templates/welcome.txt @@ -0,0 +1,16 @@ +Bienvenue {name}, + +Un compte a été créé pour vous sur EPTM Dashboard avec l'identifiant : {username} + +Pour activer votre compte, définissez votre mot de passe en cliquant sur le lien ci-dessous : + +{link} + +Ce lien est valable jusqu'au {expiry} ({ttl_human}). + +À votre prochaine connexion, l'application vous demandera de configurer un second facteur d'authentification (TOTP) via une application comme Google Authenticator, Authy, 1Password ou Microsoft Authenticator. + +Si vous n'êtes pas à l'origine de cette demande, ignorez cet email. + +Cordialement, +L'équipe EPTM diff --git a/data/settings.json b/data/settings.json index 08e6616..4df381e 100644 --- a/data/settings.json +++ b/data/settings.json @@ -6,5 +6,6 @@ "smtp_sender": "EPTM Automation ", "escada_username": "julien.balet", "escada_password": "Lauryne2023!", - "totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR" + "totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR", + "app_base_url": "https://dev.dashboard.eptm-automation.ch" } \ No newline at end of file diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index aceded6..b225038 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -11,6 +11,8 @@ from .pages.users import users_page, UsersState from .pages.params import params_page, ParamsState from .pages.purge import purge_page, PurgeState from .pages.doc import doc_page, DocState +from .pages.profile import profile_page, ProfileState +from .pages.password_set import password_set_page, PasswordSetState TITLE = "EPTM Dashboard" @@ -47,3 +49,6 @@ app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE) app.add_page(purge_page, route="/purge", on_load=[AuthState.check_auth, PurgeState.load_data], title=TITLE) app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE) +app.add_page(profile_page, route="/profile", on_load=[AuthState.check_auth, ProfileState.load_data], title=TITLE) +# Page publique (pas de check_auth — accessible via lien email) +app.add_page(password_set_page, route="/password-set", on_load=PasswordSetState.load_data, title=TITLE) diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index cb3386b..dc6de9f 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -4,10 +4,12 @@ from collections import defaultdict sys.path.insert(0, "/opt/eptm-dashboard") import reflex as rx -from src.db import get_session +from src.db import get_session, Apprenti, Absence from src.stats import kpis, alertes_quota_absences from src.sanction_pdf import generate_avis_pdf from src.logger import app_log +from src.user_access import get_allowed_classes, is_class_allowed +from sqlalchemy import select, func from ..state import AuthState from ..sidebar import layout from .fiche import FicheState @@ -30,10 +32,18 @@ class AccueilState(AuthState): try: sess = get_session() try: - k = kpis(sess) - self.kpi_mois = k["total_ce_mois"] - self.kpi_total = k["total_global"] - self.kpi_traiter = k["n_a_traiter"] + allowed = get_allowed_classes(self.username) + + # KPIs : recalcul filtré si l'utilisateur est restreint + if allowed is None: + k = kpis(sess) + self.kpi_mois = k["total_ce_mois"] + self.kpi_total = k["total_global"] + self.kpi_traiter = k["n_a_traiter"] + else: + self.kpi_mois, self.kpi_total, self.kpi_traiter = self._filtered_kpis( + sess, allowed, + ) df = alertes_quota_absences(sess, seuil=5) items = [ @@ -46,6 +56,9 @@ class AccueilState(AuthState): } for _, row in df.iterrows() ] + # Filtrage selon les classes autorisées + if allowed is not None: + items = [it for it in items if it["classe"] in allowed] # Groupement par classe (tri alphabétique des classes, # puis par nom dans chaque classe). grouped: dict[str, list[dict]] = defaultdict(list) @@ -66,6 +79,38 @@ class AccueilState(AuthState): except Exception as e: print(f"[accueil] erreur: {e}") + @staticmethod + def _filtered_kpis(sess, allowed: list[str]) -> tuple[int, int, int]: + """Recalcule les KPIs sur les apprentis appartenant aux classes autorisées.""" + from datetime import date as _date + if not allowed: + return 0, 0, 0 + ids = sess.execute( + select(Apprenti.id).where(Apprenti.classe.in_(allowed)) + ).scalars().all() + ids = list(ids) + if not ids: + return 0, 0, 0 + today = _date.today() + first_of_month = today.replace(day=1) + # Total ce mois (même logique : count d'absences sur la période) + total_mois = sess.execute( + select(func.count(Absence.id)).where( + Absence.apprenti_id.in_(ids), + Absence.date >= first_of_month, + ) + ).scalar() or 0 + total_global = sess.execute( + select(func.count(Absence.id)).where(Absence.apprenti_id.in_(ids)) + ).scalar() or 0 + n_traiter = sess.execute( + select(func.count(Absence.id)).where( + Absence.apprenti_id.in_(ids), + Absence.statut == "a_traiter", + ) + ).scalar() or 0 + return int(total_mois), int(total_global), int(n_traiter) + # ── Navigation cross-page (pré-sélection) ──────────────────────────────── def open_fiche(self, apprenti_id: int): @@ -83,6 +128,9 @@ class AccueilState(AuthState): # ── Téléchargement de l'avis de sanction ───────────────────────────────── def download_avis(self, apprenti_id: int, nom: str, prenom: str, classe: str): + # Garde-fou : refuse si la classe n'est pas autorisée + if not is_class_allowed(self.username, classe): + return rx.toast.error("Accès refusé pour cette classe.") sess = get_session() try: data = generate_avis_pdf( diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py index 57d0f50..d4364e0 100644 --- a/eptm_dashboard/pages/classe.py +++ b/eptm_dashboard/pages/classe.py @@ -17,6 +17,7 @@ from src.db import ( ) from src.stats import nb_blocs_absences, synthese_classe from src.parser_bn import sem_short_label, sem_year_only +from src.user_access import get_allowed_classes, is_class_allowed QUOTA = 5 @@ -379,6 +380,10 @@ class ClasseState(AuthState): ).scalars().all() # Filtrer les classes MP / MI (formations maturité, hors scope) classes = [c for c in classes if c and not c.startswith(("MP", "MI"))] + # Filtrer selon les classes autorisées pour cet utilisateur + allowed = get_allowed_classes(self.username) + if allowed is not None: + classes = [c for c in classes if c in allowed] if not classes: self.has_classes = False self.classes = [] @@ -392,6 +397,9 @@ class ClasseState(AuthState): self._reload() def set_class(self, classe: str): + # Garde-fou : refuse une classe non autorisée + if not is_class_allowed(self.username, classe): + return self.selected_class = classe self.class_select_open = False self.class_search = "" diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py index 16982f9..4eb57f4 100644 --- a/eptm_dashboard/pages/fiche.py +++ b/eptm_dashboard/pages/fiche.py @@ -22,6 +22,7 @@ from src.stats import nb_blocs_absences from src.parser_bn import sem_short_label, sem_year_only from src.email_sender import build_template_vars, render_template from src.logger import app_log +from src.user_access import get_allowed_classes, is_class_allowed from ..components import empty_state MOIS_FR = [ @@ -503,9 +504,11 @@ class FicheState(AuthState): if not self.authenticated: return rx.redirect("/login") sess = get_session() - apprentis = sess.execute( - select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom) - ).scalars().all() + allowed = get_allowed_classes(self.username) + q = select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom) + if allowed is not None: + q = q.where(Apprenti.classe.in_(allowed)) + apprentis = sess.execute(q).scalars().all() if not apprentis: self.has_apprentis = False self.apprenti_labels = [] @@ -552,6 +555,18 @@ class FicheState(AuthState): self.apprenti_search = "" def navigate_to(self, apprenti_id: int): + # Garde-fou : revérifie que l'apprenti est dans le scope autorisé + sess = get_session() + try: + ap = sess.get(Apprenti, apprenti_id) + if ap is None or not is_class_allowed(self.username, ap.classe): + return + finally: + sess.close() + # Si l'apprenti n'est pas dans la liste actuelle (ex: liste pas encore + # chargée), on la recharge — load_data filtre déjà selon les droits. + if apprenti_id not in self.apprenti_ids: + self.load_data() if apprenti_id in self.apprenti_ids: idx = self.apprenti_ids.index(apprenti_id) self.selected_id = apprenti_id diff --git a/eptm_dashboard/pages/params.py b/eptm_dashboard/pages/params.py index a9dab2c..2ea7c78 100644 --- a/eptm_dashboard/pages/params.py +++ b/eptm_dashboard/pages/params.py @@ -63,6 +63,10 @@ class ParamsState(AuthState): email_body: str = "" save_ok_template: bool = False + # ── App ─────────────────────────────────────────────────────────────────── + app_base_url: str = "" + save_ok_app: bool = False + # ── Setters ─────────────────────────────────────────────────────────────── def set_texte_sanction(self, v: str): self.texte_sanction = v def set_chef_section(self, v: str): self.chef_section = v @@ -76,6 +80,7 @@ class ParamsState(AuthState): def set_totp_secret(self, v: str): self.totp_secret = v def set_email_subject(self, v: str): self.email_subject = v def set_email_body(self, v: str): self.email_body = v + def set_app_base_url(self, v: str): self.app_base_url = v def load_data(self): if not self.authenticated: @@ -93,10 +98,12 @@ class ParamsState(AuthState): self.totp_secret = s.get("totp_secret", "") self.email_subject = s.get("email_subject", _DEFAULT_TEMPLATE_SUBJ) self.email_body = s.get("email_body", _DEFAULT_TEMPLATE_BODY) + self.app_base_url = s.get("app_base_url", "https://dev.dashboard.eptm-automation.ch") self.save_ok_sanction = False self.save_ok_smtp = False self.save_ok_escada = False self.save_ok_template = False + self.save_ok_app = False def save_sanctions(self): s = _read_settings() @@ -145,6 +152,17 @@ class ParamsState(AuthState): self.save_ok_sanction = False self.save_ok_smtp = False self.save_ok_escada = False + self.save_ok_app = False + + def save_app(self): + s = _read_settings() + s["app_base_url"] = self.app_base_url.strip().rstrip("/") + _write_settings(s) + self.save_ok_app = True + self.save_ok_sanction = False + self.save_ok_smtp = False + self.save_ok_escada = False + self.save_ok_template = False # ── UI helpers ──────────────────────────────────────────────────────────────── @@ -427,12 +445,43 @@ def _section_template() -> rx.Component: ) +def _section_app() -> rx.Component: + return _section( + "Application", + rx.text( + "URL de base de l'application — utilisée pour générer les liens " + "envoyés par email (création de compte, réinitialisation de mot de passe).", + size="1", color="var(--gray-11)", + ), + _field( + "URL de base (sans /)", + rx.input( + value=ParamsState.app_base_url, + on_change=ParamsState.set_app_base_url, + placeholder="https://dashboard.eptm-automation.ch", + width="100%", + ), + ), + rx.hstack( + rx.button( + rx.icon("save", size=16), + "Enregistrer", + on_click=ParamsState.save_app, + color_scheme="blue", variant="solid", size="2", + ), + _save_ok_callout(ParamsState.save_ok_app), + spacing="3", align="center", flex_wrap="wrap", + ), + ) + + # ── Page ────────────────────────────────────────────────────────────────────── def params_page() -> rx.Component: return layout( rx.vstack( rx.heading("Paramètres", size="7"), + _section_app(), _section_sanction(), _section_smtp(), _section_escada(), diff --git a/eptm_dashboard/pages/password_set.py b/eptm_dashboard/pages/password_set.py new file mode 100644 index 0000000..b7e5016 --- /dev/null +++ b/eptm_dashboard/pages/password_set.py @@ -0,0 +1,303 @@ +"""Page /password-set — définition / réinitialisation de mot de passe via token. + +Page **publique** : accessible sans authentification, via un lien envoyé par email. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import bcrypt +import yaml +import reflex as rx + +_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from src.password_tokens import validate_token, consume_token # noqa: E402 +from src.logger import app_log # noqa: E402 + +from ..state import AuthState + +DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) +AUTH_FILE = DATA_DIR / "auth.yaml" + + +def _load_auth() -> dict: + if AUTH_FILE.exists(): + with open(AUTH_FILE, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + return {} + + +def _save_auth(cfg: dict) -> None: + with open(AUTH_FILE, "w", encoding="utf-8") as f: + yaml.dump(cfg, f, allow_unicode=True) + + +# ── State ───────────────────────────────────────────────────────────────────── + + +class PasswordSetState(AuthState): + # Lecture token + token: str = "" + token_valid: bool = False + token_username: str = "" + token_name: str = "" + token_kind: str = "" # "set" | "reset" + token_error: str = "" + + # Form + new_pwd: str = "" + confirm_pwd: str = "" + form_error: str = "" + success: bool = False + + def set_new_pwd(self, v: str): self.new_pwd = v + def set_confirm_pwd(self, v: str): self.confirm_pwd = v + + def load_data(self): + # Reset state + self.token_valid = False + self.token_username = "" + self.token_name = "" + self.token_kind = "" + self.token_error = "" + self.new_pwd = "" + self.confirm_pwd = "" + self.form_error = "" + self.success = False + + # Récupérer le token depuis les query params (?token=...) + try: + params = self.router.url.query_parameters + token = params.get("token", "") if params else "" + except Exception: + token = "" + self.token = token or "" + + if not self.token: + self.token_error = "Lien invalide : token manquant." + return + + info = validate_token(self.token) + if not info: + self.token_error = ( + "Ce lien n'est plus valide ou a expiré. " + "Demandez à un administrateur de vous en envoyer un nouveau." + ) + return + + # Récupérer les infos utilisateur + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + username = info["username"] + user = users.get(username) + if not user: + self.token_error = "Utilisateur introuvable." + return + + self.token_valid = True + self.token_username = username + self.token_name = user.get("name", username) + self.token_kind = info["type"] + + def submit(self, form_data: dict | None = None): + self.form_error = "" + if not self.token_valid: + return + if len(self.new_pwd) < 8: + self.form_error = "Le mot de passe doit faire au moins 8 caractères." + return + if self.new_pwd != self.confirm_pwd: + self.form_error = "Les mots de passe ne correspondent pas." + return + + # Re-vérifier le token (peut avoir expiré entretemps) + info = validate_token(self.token) + if not info: + self.token_valid = False + self.token_error = "Ce lien n'est plus valide. Demandez-en un nouveau." + return + + username = info["username"] + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + if username not in users: + self.form_error = "Utilisateur introuvable." + return + + # Hash + sauvegarde + users[username]["password"] = bcrypt.hashpw( + self.new_pwd.encode(), bcrypt.gensalt(12) + ).decode() + _save_auth(cfg) + + # Token consommé + consume_token(self.token) + + kind_label = "défini" if self.token_kind == "set" else "réinitialisé" + app_log(f"[auth] mot de passe {kind_label} pour {username} via token") + + self.success = True + self.new_pwd = "" + self.confirm_pwd = "" + + def go_to_login(self): + return rx.redirect("/login") + + +# ── UI ──────────────────────────────────────────────────────────────────────── + + +def _logo() -> rx.Component: + return rx.center( + rx.image(src="/logo.png", width="320px", height="auto"), + width="100%", + ) + + +def _form_content() -> rx.Component: + return rx.form( + rx.vstack( + _logo(), + rx.heading( + rx.cond( + PasswordSetState.token_kind == "set", + "Activez votre compte", + "Réinitialisation du mot de passe", + ), + size="4", color="#37474f", + ), + rx.text( + "Compte : ", + rx.text.strong(PasswordSetState.token_username), + " — ", + PasswordSetState.token_name, + size="2", color="var(--gray-11)", text_align="center", + ), + rx.cond( + PasswordSetState.token_kind == "set", + rx.callout.root( + rx.callout.icon(rx.icon("info", size=16)), + rx.callout.text( + "Après définition de votre mot de passe, vous pourrez vous " + "connecter et configurer un second facteur d'authentification " + "(application Authenticator)." + ), + color_scheme="blue", variant="soft", size="1", + ), + rx.fragment(), + ), + rx.input( + name="new_pwd", + value=PasswordSetState.new_pwd, + on_change=PasswordSetState.set_new_pwd, + type="password", + placeholder="Nouveau mot de passe (8 caractères min.)", + width="100%", + auto_focus=True, + ), + rx.input( + name="confirm_pwd", + value=PasswordSetState.confirm_pwd, + on_change=PasswordSetState.set_confirm_pwd, + type="password", + placeholder="Confirmer le mot de passe", + width="100%", + ), + rx.cond( + PasswordSetState.form_error != "", + rx.box( + rx.text(PasswordSetState.form_error, color="red", size="2"), + padding="0.5rem 1rem", + background_color="#fff5f5", + border="1px solid #ffcccc", + border_radius="6px", + width="100%", + ), + rx.fragment(), + ), + rx.button( + "Définir mon mot de passe", + type="submit", + width="100%", + color_scheme="indigo", + ), + spacing="3", + width="100%", + align="center", + ), + on_submit=PasswordSetState.submit, + width="100%", + ) + + +def _success_content() -> rx.Component: + return rx.vstack( + _logo(), + rx.icon("circle-check-big", size=42, color="#15803d"), + rx.heading("Mot de passe enregistré", size="4", color="#15803d"), + rx.text( + "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.", + size="2", color="var(--gray-11)", text_align="center", + ), + rx.button( + "Aller à la page de connexion", + on_click=PasswordSetState.go_to_login, + width="100%", + color_scheme="indigo", + ), + spacing="3", + width="100%", + align="center", + ) + + +def _error_content() -> rx.Component: + return rx.vstack( + _logo(), + rx.icon("triangle-alert", size=42, color="#b91c1c"), + rx.heading("Lien invalide", size="4", color="#7f1d1d"), + rx.text( + PasswordSetState.token_error, + size="2", color="var(--gray-11)", text_align="center", + ), + rx.button( + "Aller à la page de connexion", + on_click=PasswordSetState.go_to_login, + width="100%", + variant="soft", + color_scheme="gray", + ), + spacing="3", + width="100%", + align="center", + ) + + +def password_set_page() -> rx.Component: + return rx.center( + rx.box( + rx.cond( + PasswordSetState.success, + _success_content(), + rx.cond( + PasswordSetState.token_valid, + _form_content(), + _error_content(), + ), + ), + width="420px", + padding="2rem", + background_color="white", + border_radius="8px", + box_shadow="0 2px 16px rgba(0,0,0,0.08)", + ), + width="100%", + height="100vh", + background_color="#f8f9fa", + ) diff --git a/eptm_dashboard/pages/profile.py b/eptm_dashboard/pages/profile.py new file mode 100644 index 0000000..079ed69 --- /dev/null +++ b/eptm_dashboard/pages/profile.py @@ -0,0 +1,468 @@ +"""Page /profile — gestion du compte de l'utilisateur connecté.""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +import bcrypt +import yaml +import reflex as rx + +_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from src.logger import app_log # noqa: E402 + +from ..state import AuthState +from ..sidebar import layout + +DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) +AUTH_FILE = DATA_DIR / "auth.yaml" +_AVATARS_DIR = _ROOT / "assets" / "avatars" + + +def _load_auth() -> dict: + if AUTH_FILE.exists(): + with open(AUTH_FILE, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + return {} + + +def _save_auth(cfg: dict) -> None: + with open(AUTH_FILE, "w", encoding="utf-8") as f: + yaml.dump(cfg, f, allow_unicode=True) + + +# ── State ───────────────────────────────────────────────────────────────────── + +class ProfileState(AuthState): + profile_name: str = "" + profile_email: str = "" + profile_role: str = "" + profile_has_totp: bool = False + profile_avatar: str = "" + + # Form: change name/email + edit_name: str = "" + edit_email: str = "" + info_ok: bool = False + info_error: str = "" + + # Form: change password + pwd_current: str = "" + pwd_new: str = "" + pwd_confirm: str = "" + pwd_ok: bool = False + pwd_error: str = "" + + # 2FA + totp_reset_ok: bool = False + + # Avatar + upload_ok: bool = False + + def set_edit_name(self, v: str): self.edit_name = v + def set_edit_email(self, v: str): self.edit_email = v + def set_pwd_current(self, v: str): self.pwd_current = v + def set_pwd_new(self, v: str): self.pwd_new = v + def set_pwd_confirm(self, v: str): self.pwd_confirm = v + + def load_data(self): + if not self.authenticated: + return rx.redirect("/login") + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + u = users.get(self.username, {}) + self.profile_name = u.get("name", self.username) + self.profile_email = u.get("email", "") + self.profile_role = u.get("role", "user") + self.profile_has_totp = bool(u.get("totp_secret")) + self.profile_avatar = u.get("avatar_url", "") + self.edit_name = self.profile_name + self.edit_email = self.profile_email + self.info_ok = False + self.info_error = "" + self.pwd_current = "" + self.pwd_new = "" + self.pwd_confirm = "" + self.pwd_ok = False + self.pwd_error = "" + self.totp_reset_ok = False + self.upload_ok = False + + def save_info(self): + self.info_error = "" + self.info_ok = False + if not self.edit_name.strip(): + self.info_error = "Le nom ne peut pas être vide." + return + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + u = users.get(self.username) + if not u: + self.info_error = "Compte introuvable." + return + u["name"] = self.edit_name.strip() + u["email"] = self.edit_email.strip() + _save_auth(cfg) + self.profile_name = u["name"] + self.profile_email = u["email"] + self.name = u["name"] # Met à jour le nom dans la sidebar (LocalStorage) + self.info_ok = True + app_log(f"[profile] {self.username} : informations mises à jour") + + def save_password(self): + self.pwd_error = "" + self.pwd_ok = False + if len(self.pwd_new) < 8: + self.pwd_error = "Le nouveau mot de passe doit faire au moins 8 caractères." + return + if self.pwd_new != self.pwd_confirm: + self.pwd_error = "Les mots de passe ne correspondent pas." + return + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + u = users.get(self.username) + if not u: + self.pwd_error = "Compte introuvable." + return + try: + ok = bcrypt.checkpw(self.pwd_current.encode(), u.get("password", "").encode()) + except Exception: + ok = False + if not ok: + self.pwd_error = "Mot de passe actuel incorrect." + return + u["password"] = bcrypt.hashpw(self.pwd_new.encode(), bcrypt.gensalt(12)).decode() + _save_auth(cfg) + self.pwd_current = "" + self.pwd_new = "" + self.pwd_confirm = "" + self.pwd_ok = True + app_log(f"[profile] {self.username} : mot de passe modifié") + + def reset_my_totp(self): + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + u = users.get(self.username) + if not u: + return + u["totp_secret"] = None + _save_auth(cfg) + self.profile_has_totp = False + self.totp_reset_ok = True + app_log(f"[profile] {self.username} : 2FA réinitialisé") + + async def handle_avatar_upload(self, files: list[rx.UploadFile]): + if not files: + return + file = files[0] + data = await file.read() + if not data: + return + uname = self.username + fname = (getattr(file, "filename", None) or "photo.jpg").lower() + ext = fname.rsplit(".", 1)[-1] if "." in fname else "jpg" + if ext not in ("jpg", "jpeg", "png", "gif", "webp"): + ext = "jpg" + if ext == "jpeg": + ext = "jpg" + _AVATARS_DIR.mkdir(parents=True, exist_ok=True) + for old in _AVATARS_DIR.glob(f"{uname}.*"): + old.unlink(missing_ok=True) + (_AVATARS_DIR / f"{uname}.{ext}").write_bytes(data) + url = f"/avatars/{uname}.{ext}?t={int(time.time())}" + cfg = _load_auth() + cfg["credentials"]["usernames"][uname]["avatar_url"] = url + _save_auth(cfg) + self.photo_url = url + self.profile_avatar = url + self.upload_ok = True + + def remove_avatar(self): + uname = self.username + for old in _AVATARS_DIR.glob(f"{uname}.*"): + old.unlink(missing_ok=True) + cfg = _load_auth() + cfg["credentials"]["usernames"][uname].pop("avatar_url", None) + _save_auth(cfg) + self.photo_url = "" + self.profile_avatar = "" + self.upload_ok = False + + +# ── UI helpers ──────────────────────────────────────────────────────────────── + +def _label(text: str) -> rx.Component: + return rx.text(text, size="2", weight="medium", color="var(--gray-11)") + + +def _ok(show, msg) -> rx.Component: + return rx.cond( + show, + rx.callout.root( + rx.callout.icon(rx.icon("check", size=16)), + rx.callout.text(msg), + color_scheme="green", variant="soft", size="1", + ), + rx.fragment(), + ) + + +def _err(msg) -> rx.Component: + return rx.cond( + msg != "", + rx.callout.root( + rx.callout.icon(rx.icon("triangle-alert", size=16)), + rx.callout.text(msg), + color_scheme="red", variant="soft", size="1", + ), + rx.fragment(), + ) + + +# ── Sections ────────────────────────────────────────────────────────────────── + +def _avatar_section() -> rx.Component: + has_photo = ProfileState.profile_avatar != "" + return rx.box( + rx.vstack( + rx.text("Photo de profil", size="3", weight="bold"), + rx.hstack( + rx.cond( + has_photo, + rx.image( + src=ProfileState.profile_avatar, + width="64px", height="64px", + border_radius="50%", object_fit="cover", + border="2px solid var(--gray-4)", + ), + rx.image( + src="/default_avatar.svg", + width="64px", height="64px", border_radius="50%", + ), + ), + rx.vstack( + rx.upload.root( + rx.button( + rx.icon("upload", size=15), + rx.cond(has_photo, "Changer la photo", "Choisir une photo"), + variant="outline", color_scheme="blue", size="2", + ), + id="profile_avatar_upload", + on_drop=ProfileState.handle_avatar_upload, + accept={"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], + "image/gif": [".gif"], "image/webp": [".webp"]}, + max_files=1, multiple=False, + ), + rx.cond( + has_photo, + rx.button( + rx.icon("trash-2", size=14), + "Supprimer", + on_click=ProfileState.remove_avatar, + variant="ghost", color_scheme="red", size="1", + ), + rx.fragment(), + ), + _ok(ProfileState.upload_ok, "Photo mise à jour."), + spacing="2", align="start", + ), + spacing="4", align="center", flex_wrap="wrap", + ), + spacing="3", width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +def _info_section() -> rx.Component: + return rx.box( + rx.vstack( + rx.text("Informations", size="3", weight="bold"), + rx.hstack( + rx.vstack( + _label("Identifiant"), + rx.input(value=ProfileState.username, disabled=True, width="100%"), + spacing="1", flex="1", min_width="0", width="100%", + ), + rx.vstack( + _label("Rôle"), + rx.input(value=ProfileState.profile_role, disabled=True, width="100%"), + spacing="1", flex="1", min_width="0", width="100%", + ), + spacing="4", width="100%", flex_wrap="wrap", + ), + rx.hstack( + rx.vstack( + _label("Nom affiché"), + rx.input( + value=ProfileState.edit_name, + on_change=ProfileState.set_edit_name, + width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + rx.vstack( + _label("Email"), + rx.input( + value=ProfileState.edit_email, + on_change=ProfileState.set_edit_email, + placeholder="email@domaine.ch", + width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + spacing="4", width="100%", flex_wrap="wrap", + ), + rx.hstack( + rx.button( + rx.icon("save", size=16), + "Mettre à jour", + on_click=ProfileState.save_info, + color_scheme="blue", size="2", + ), + _ok(ProfileState.info_ok, "Informations enregistrées."), + _err(ProfileState.info_error), + spacing="3", align="center", flex_wrap="wrap", + ), + spacing="3", width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +def _password_section() -> rx.Component: + return rx.box( + rx.vstack( + rx.text("Changer mon mot de passe", size="3", weight="bold"), + rx.vstack( + _label("Mot de passe actuel"), + rx.input( + value=ProfileState.pwd_current, + on_change=ProfileState.set_pwd_current, + type="password", width="100%", + ), + spacing="1", width="100%", + ), + rx.hstack( + rx.vstack( + _label("Nouveau mot de passe"), + rx.input( + value=ProfileState.pwd_new, + on_change=ProfileState.set_pwd_new, + type="password", placeholder="Minimum 8 caractères", + width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + rx.vstack( + _label("Confirmer"), + rx.input( + value=ProfileState.pwd_confirm, + on_change=ProfileState.set_pwd_confirm, + type="password", width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + spacing="4", width="100%", flex_wrap="wrap", + ), + rx.hstack( + rx.button( + rx.icon("lock", size=16), + "Mettre à jour le mot de passe", + on_click=ProfileState.save_password, + color_scheme="blue", size="2", + ), + _ok(ProfileState.pwd_ok, "Mot de passe modifié."), + _err(ProfileState.pwd_error), + spacing="3", align="center", flex_wrap="wrap", + ), + spacing="3", width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +def _totp_section() -> rx.Component: + return rx.box( + rx.vstack( + rx.text("Authentification à 2 facteurs", size="3", weight="bold"), + rx.hstack( + rx.text("Statut :", size="2", flex_shrink="0"), + rx.cond( + ProfileState.totp_reset_ok, + rx.badge("Réinitialisé", color_scheme="orange", variant="soft"), + rx.cond( + ProfileState.profile_has_totp, + rx.badge("Actif", color_scheme="green", variant="soft"), + rx.badge("Non configuré", color_scheme="gray", variant="soft"), + ), + ), + spacing="2", align="center", + ), + rx.cond( + ProfileState.profile_has_totp & ~ProfileState.totp_reset_ok, + rx.vstack( + rx.text( + "Réinitialise ton 2FA si tu as perdu accès à ton application " + "d'authentification. Un nouveau QR code sera affiché à ta prochaine connexion.", + size="1", color="var(--gray-11)", + ), + rx.button( + rx.icon("rotate-ccw", size=16), + "Réinitialiser mon 2FA", + on_click=ProfileState.reset_my_totp, + color_scheme="orange", variant="outline", size="2", + ), + spacing="2", align="start", width="100%", + ), + rx.fragment(), + ), + rx.cond( + ~ProfileState.profile_has_totp, + rx.text( + "Un QR code sera demandé à ta prochaine connexion pour configurer ton 2FA.", + size="1", color="var(--gray-10)", + ), + rx.fragment(), + ), + spacing="3", width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +def profile_page() -> rx.Component: + return layout( + rx.vstack( + rx.heading("Mon profil", size="6"), + _avatar_section(), + _info_section(), + _password_section(), + _totp_section(), + spacing="4", + width="100%", + max_width="780px", + ) + ) diff --git a/eptm_dashboard/pages/users.py b/eptm_dashboard/pages/users.py index 1b1cb61..d1cdd4e 100644 --- a/eptm_dashboard/pages/users.py +++ b/eptm_dashboard/pages/users.py @@ -1,15 +1,24 @@ import os +import sys import time from pathlib import Path -import bcrypt import yaml import reflex as rx +_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from src.password_tokens import create_token, revoke_all_for_user # noqa: E402 +from src.password_emails import send_password_email # noqa: E402 +from src.logger import app_log # noqa: E402 +from src.db import get_session, Apprenti # noqa: E402 +from sqlalchemy import select # noqa: E402 + from ..sidebar import layout from ..state import AuthState -_ROOT = Path(__file__).resolve().parent.parent.parent DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) AUTH_FILE = DATA_DIR / "auth.yaml" _AVATARS_DIR = _ROOT / "assets" / "avatars" @@ -29,6 +38,7 @@ def _save_auth(cfg: dict) -> None: # ── State ───────────────────────────────────────────────────────────────────── + class UsersState(AuthState): users_list: list[dict] = [] @@ -40,12 +50,6 @@ class UsersState(AuthState): info_error: str = "" info_ok: bool = False - # Change password - pwd_new: str = "" - pwd_confirm: str = "" - pwd_error: str = "" - pwd_ok: bool = False - # 2FA edit_has_totp: bool = False totp_ok: bool = False @@ -54,28 +58,43 @@ class UsersState(AuthState): edit_avatar_url: str = "" upload_ok: bool = False + # Reset password + reset_target: str = "" # username sur lequel un reset est demandé (pour modale) + # Add user (admin) new_uname: str = "" new_name: str = "" new_email: str = "" new_role: str = "user" - new_pwd1: str = "" - new_pwd2: str = "" new_error: str = "" new_ok: bool = False + # Accès aux classes (édition) + all_classes: list[str] = [] + edit_restrict: bool = False # True = restriction active + edit_classes: list[str] = [] # classes cochées + classes_open: bool = False # popover ouvert + classes_search: str = "" + access_ok: bool = False + access_error: str = "" + # ── Setters ─────────────────────────────────────────────────────────────── def set_edit_name(self, v: str): self.edit_name = v def set_edit_email(self, v: str): self.edit_email = v def set_edit_role(self, v: str): self.edit_role = v - def set_pwd_new(self, v: str): self.pwd_new = v - def set_pwd_confirm(self, v: str): self.pwd_confirm = v def set_new_uname(self, v: str): self.new_uname = v def set_new_name(self, v: str): self.new_name = v def set_new_email(self, v: str): self.new_email = v def set_new_role(self, v: str): self.new_role = v - def set_new_pwd1(self, v: str): self.new_pwd1 = v - def set_new_pwd2(self, v: str): self.new_pwd2 = v + def set_classes_search(self, v: str): self.classes_search = v + def set_classes_open(self, v: bool): self.classes_open = v + + @rx.var + def filtered_all_classes(self) -> list[str]: + q = self.classes_search.lower().strip() + if not q: + return self.all_classes + return [c for c in self.all_classes if q in c.lower()] # ── Helpers ─────────────────────────────────────────────────────────────── def _refresh_list(self): @@ -102,22 +121,42 @@ class UsersState(AuthState): self.edit_role = udata.get("role", "user") self.edit_has_totp = bool(udata.get("totp_secret")) self.edit_avatar_url = udata.get("avatar_url", "") + # Accès aux classes : si la clé est absente → pas de restriction (None) + # Si présente (même []) → restriction active + allowed = udata.get("allowed_classes") + self.edit_restrict = allowed is not None + self.edit_classes = list(allowed) if allowed else [] + self.classes_open = False + self.classes_search = "" self.info_error = "" self.info_ok = False - self.pwd_new = "" - self.pwd_confirm = "" - self.pwd_error = "" - self.pwd_ok = False self.totp_ok = False self.upload_ok = False + self.access_ok = False + self.access_error = "" # ── Handlers ────────────────────────────────────────────────────────────── def load_data(self): if not self.authenticated: return rx.redirect("/login") - self._refresh_list() if self.role != "admin": - self._populate_edit(self.username) + return rx.redirect("/profile") + self._refresh_list() + self._refresh_classes() + + def _refresh_classes(self): + """Charge la liste des classes disponibles depuis la DB (hors MP/MI).""" + sess = get_session() + try: + cs = sess.execute( + select(Apprenti.classe).distinct().order_by(Apprenti.classe) + ).scalars().all() + self.all_classes = [ + c for c in cs + if c and not c.startswith(("MP", "MI")) + ] + finally: + sess.close() def select_user(self, uname: str): if self.edit_target == uname: @@ -142,30 +181,59 @@ class UsersState(AuthState): return users[uname]["name"] = self.edit_name.strip() users[uname]["email"] = self.edit_email.strip() - if self.role == "admin" and uname != self.username: + if uname != self.username: users[uname]["role"] = self.edit_role _save_auth(cfg) self.info_ok = True + app_log(f"[users] {self.username} : informations modifiées pour {uname}") self._refresh_list() - def save_password(self): - self.pwd_error = "" - self.pwd_ok = False - if len(self.pwd_new) < 6: - self.pwd_error = "Minimum 6 caractères." + def toggle_restrict(self, v: bool): + self.edit_restrict = v + self.access_ok = False + if not v: + self.edit_classes = [] + + def toggle_class(self, classe: str): + cur = list(self.edit_classes) + if classe in cur: + cur.remove(classe) + else: + cur.append(classe) + self.edit_classes = cur + self.access_ok = False + + def select_all_classes(self): + self.edit_classes = list(self.all_classes) + self.access_ok = False + + def clear_all_classes(self): + self.edit_classes = [] + self.access_ok = False + + def save_access(self): + self.access_error = "" + self.access_ok = False + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + uname = self.edit_target + if uname not in users: + self.access_error = "Utilisateur introuvable." return - if self.pwd_new != self.pwd_confirm: - self.pwd_error = "Les mots de passe ne correspondent pas." - return - cfg = _load_auth() - users = cfg["credentials"]["usernames"] - users[self.edit_target]["password"] = bcrypt.hashpw( - self.pwd_new.encode(), bcrypt.gensalt(12) - ).decode() + if self.edit_restrict: + users[uname]["allowed_classes"] = list(self.edit_classes) + else: + users[uname].pop("allowed_classes", None) _save_auth(cfg) - self.pwd_new = "" - self.pwd_confirm = "" - self.pwd_ok = True + nb = len(self.edit_classes) if self.edit_restrict else 0 + if self.edit_restrict: + app_log( + f"[users] {self.username} : restriction d'accès pour {uname} → " + f"{nb} classe(s)" + ) + else: + app_log(f"[users] {self.username} : accès complet rétabli pour {uname}") + self.access_ok = True def reset_totp(self): cfg = _load_auth() @@ -175,6 +243,7 @@ class UsersState(AuthState): self._populate_edit(self.edit_target) self._refresh_list() self.totp_ok = True + app_log(f"[users] {self.username} : 2FA réinitialisé pour {self.edit_target}") async def handle_avatar_upload(self, files: list[rx.UploadFile]): if not files: @@ -225,8 +294,10 @@ class UsersState(AuthState): if uname in users: del users[uname] _save_auth(cfg) + revoke_all_for_user(uname) if self.edit_target == uname: self.edit_target = "" + app_log(f"[users] {self.username} : suppression du compte {uname}") self._refresh_list() def add_user(self): @@ -234,40 +305,106 @@ class UsersState(AuthState): self.new_ok = False uname = self.new_uname.strip().lower() name = self.new_name.strip() + email = self.new_email.strip() errs = [] if not uname or not name: errs.append("L'identifiant et le nom sont obligatoires.") elif " " in uname: errs.append("L'identifiant ne doit pas contenir d'espaces.") - if len(self.new_pwd1) < 6: - errs.append("Mot de passe trop court (6 caractères minimum).") - if self.new_pwd1 != self.new_pwd2: - errs.append("Les mots de passe ne correspondent pas.") + if not email or "@" not in email: + errs.append("L'email est obligatoire et doit être valide.") if errs: self.new_error = " — ".join(errs) return + cfg = _load_auth() - users = cfg["credentials"]["usernames"] + users = cfg.setdefault("credentials", {}).setdefault("usernames", {}) if uname in users: self.new_error = f"L'identifiant « {uname} » est déjà utilisé." return + users[uname] = { - "email": self.new_email.strip(), + "email": email, "name": name, "role": self.new_role, - "password": bcrypt.hashpw(self.new_pwd1.encode(), bcrypt.gensalt(12)).decode(), + "password": "", # pas de mot de passe initial — défini via lien email "totp_secret": None, } _save_auth(cfg) + + # Génère token + envoie email + try: + token, expires_at = create_token(uname, kind="set") + send_password_email( + kind="welcome", + username=uname, + name=name, + email=email, + token=token, + expires_at=expires_at, + ) + except Exception as e: + # Compte créé mais email échoué : on signale et on rollback la création ? + # On garde le compte (admin pourra retenter via reset password). + self.new_error = ( + f"Compte créé, mais l'email n'a pas pu être envoyé : {e}. " + f"Utilisez le bouton « Renvoyer le lien » pour retenter." + ) + app_log(f"[users] {self.username} : création {uname} OK mais email KO ({e})") + self._refresh_list() + return + + app_log( + f"[users] {self.username} : création compte {uname} ({email}, {self.new_role}) " + f"— email d'activation envoyé" + ) + self.new_uname = "" self.new_name = "" self.new_email = "" self.new_role = "user" - self.new_pwd1 = "" - self.new_pwd2 = "" self.new_ok = True self._refresh_list() + def send_reset_password(self, uname: str): + """Génère un token de reset et envoie l'email à l'utilisateur.""" + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + u = users.get(uname) + if not u: + return rx.toast.error(f"Utilisateur {uname!r} introuvable.") + email = (u.get("email") or "").strip() + name = u.get("name", uname) + if not email or "@" not in email: + return rx.toast.error( + f"Email manquant pour {uname}. Renseignez-le avant de réinitialiser." + ) + try: + # Détermine le type : si l'utilisateur n'a pas encore de mot de passe, + # c'est un "set" (lien plus long), sinon un "reset" (24h). + kind = "set" if not u.get("password") else "reset" + email_kind = "welcome" if kind == "set" else "reset" + token, expires_at = create_token(uname, kind=kind) + send_password_email( + kind=email_kind, + username=uname, + name=name, + email=email, + token=token, + expires_at=expires_at, + ) + except Exception as e: + return rx.toast.error(f"Échec d'envoi : {e}") + + app_log( + f"[users] {self.username} : envoi lien de " + f"{'définition' if kind == 'set' else 'réinitialisation'} à {uname} ({email})" + ) + return rx.toast.success( + f"Lien envoyé à {email}. " + f"Valide {'7 jours' if kind == 'set' else '24h'}." + ) + # ── UI helpers ──────────────────────────────────────────────────────────────── @@ -293,7 +430,7 @@ def _err_callout(msg: str) -> rx.Component: return rx.cond( msg != "", rx.callout.root( - rx.callout.icon(rx.icon("alert-circle", size=16)), + rx.callout.icon(rx.icon("triangle-alert", size=16)), rx.callout.text(msg), color_scheme="red", variant="soft", @@ -319,7 +456,7 @@ def _totp_badge(has_totp: bool) -> rx.Component: ) -# ── User table row ──────────────────────────────────────────────────────────── +# ── Dialogs ─────────────────────────────────────────────────────────────────── def _delete_dialog(user: dict) -> rx.Component: return rx.alert_dialog.root( @@ -357,12 +494,62 @@ def _delete_dialog(user: dict) -> rx.Component: ) +def _reset_pwd_dialog(user: dict) -> rx.Component: + return rx.alert_dialog.root( + rx.alert_dialog.trigger( + rx.button( + rx.icon("key-round", size=14), + "Reset mdp", + color_scheme="orange", + variant="outline", + size="1", + ), + ), + rx.alert_dialog.content( + rx.alert_dialog.title("Envoyer un lien de réinitialisation"), + rx.alert_dialog.description( + rx.vstack( + rx.text( + "Un email contenant un lien à usage unique va être envoyé à ", + rx.text.strong(user["name"]), " :", + ), + rx.text(user["email"], color="var(--blue-11)", size="2", weight="medium"), + rx.text( + "L'utilisateur pourra définir un nouveau mot de passe via ce lien. " + "Vous-même n'avez pas accès au mot de passe.", + size="1", color="var(--gray-11)", + ), + spacing="2", align="start", + ), + ), + rx.hstack( + rx.alert_dialog.cancel( + rx.button("Annuler", variant="soft", color_scheme="gray"), + ), + rx.alert_dialog.action( + rx.button( + rx.icon("send", size=14), + "Envoyer le lien", + color_scheme="orange", + on_click=UsersState.send_reset_password(user["username"]), + ), + ), + spacing="3", + justify="end", + margin_top="1rem", + ), + max_width="500px", + ), + ) + + +# ── User row ────────────────────────────────────────────────────────────────── + def _user_row(user: dict) -> rx.Component: is_selected = user["username"] == UsersState.edit_target is_me = user["username"] == UsersState.username return rx.box( rx.hstack( - # Identité : nom + badge rôle + 2FA + identifiant rx.vstack( rx.hstack( rx.text(user["name"], weight="medium", size="2", @@ -370,19 +557,15 @@ def _user_row(user: dict) -> rx.Component: _role_badge(user["role"]), _totp_badge(user["has_totp"]), rx.cond(is_me, rx.badge("vous", size="1", variant="outline"), rx.fragment()), - spacing="2", - align="center", - overflow="hidden", - flex_wrap="wrap", + spacing="2", align="center", flex_wrap="wrap", ), - rx.text(user["username"], size="1", color="var(--gray-10)", - overflow="hidden", text_overflow="ellipsis", white_space="nowrap"), - spacing="0", - flex="1", - min_width="0", - overflow="hidden", + rx.text( + user["username"], " · ", user["email"], + size="1", color="var(--gray-10)", + overflow="hidden", text_overflow="ellipsis", white_space="nowrap", + ), + spacing="0", flex="1", min_width="0", overflow="hidden", ), - # Droite : actions uniquement rx.hstack( rx.button( rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)), @@ -392,27 +575,22 @@ def _user_row(user: dict) -> rx.Component: color_scheme="blue", size="1", ), - rx.cond( - is_me, - rx.fragment(), - _delete_dialog(user), - ), - spacing="2", - align="center", - flex_shrink="0", + _reset_pwd_dialog(user), + rx.cond(is_me, rx.fragment(), _delete_dialog(user)), + spacing="2", align="center", flex_shrink="0", flex_wrap="wrap", ), align="center", justify="between", width="100%", padding="0.65rem 0.75rem", overflow="hidden", + flex_wrap="wrap", + gap="0.5rem", ), background_color=rx.cond(is_selected, "var(--blue-2)", "white"), border_radius="6px", border=rx.cond(is_selected, "1px solid var(--blue-6)", "1px solid #e0e0e0"), width="100%", - overflow="hidden", - cursor="default", ) @@ -423,24 +601,17 @@ def _edit_panel_avatar() -> rx.Component: return rx.vstack( rx.text("Photo de profil", weight="bold", size="3"), rx.hstack( - # Preview rx.cond( has_photo, rx.image( src=UsersState.edit_avatar_url, - width="64px", - height="64px", - border_radius="50%", - object_fit="cover", - border="2px solid var(--gray-4)", - flex_shrink="0", + width="64px", height="64px", + border_radius="50%", object_fit="cover", + border="2px solid var(--gray-4)", flex_shrink="0", ), rx.image( src="/default_avatar.svg", - width="64px", - height="64px", - border_radius="50%", - flex_shrink="0", + width="64px", height="64px", border_radius="50%", flex_shrink="0", ), ), rx.vstack( @@ -448,17 +619,13 @@ def _edit_panel_avatar() -> rx.Component: rx.button( rx.icon("upload", size=15), rx.cond(has_photo, "Changer la photo", "Choisir une photo"), - variant="outline", - color_scheme="blue", - size="2", + variant="outline", color_scheme="blue", size="2", ), id="avatar_upload", on_drop=UsersState.handle_avatar_upload, accept={"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "image/gif": [".gif"], "image/webp": [".webp"]}, - max_files=1, - multiple=False, - max_width="100%", + max_files=1, multiple=False, ), rx.cond( has_photo, @@ -466,34 +633,21 @@ def _edit_panel_avatar() -> rx.Component: rx.icon("trash-2", size=14), "Supprimer", on_click=UsersState.remove_avatar, - variant="ghost", - color_scheme="red", - size="1", + variant="ghost", color_scheme="red", size="1", ), rx.fragment(), ), _ok_callout(UsersState.upload_ok, "Photo mise à jour."), - rx.text( - "PNG, JPG, GIF ou WebP", - size="1", - color="var(--gray-9)", - ), - spacing="2", - align="start", - min_width="0", - width="100%", + spacing="2", align="start", min_width="0", width="100%", ), - spacing="4", - align="center", - width="100%", - flex_wrap="wrap", + spacing="4", align="center", width="100%", flex_wrap="wrap", ), - spacing="3", - width="100%", + spacing="3", width="100%", ) -def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component: +def _edit_panel_info() -> rx.Component: + is_other = UsersState.edit_target != UsersState.username return rx.vstack( rx.text("Informations du compte", weight="bold", size="3"), rx.hstack( @@ -519,7 +673,7 @@ def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component: spacing="4", width="100%", flex_wrap="wrap", ), rx.cond( - is_admin_editing_other, + is_other, rx.vstack( _label("Rôle"), rx.select.root( @@ -532,8 +686,7 @@ def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component: on_change=UsersState.set_edit_role, width="100%", ), - spacing="1", - width="100%", + spacing="1", width="100%", ), rx.fragment(), ), @@ -542,66 +695,218 @@ def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component: rx.icon("save", size=16), "Mettre à jour", on_click=UsersState.save_info, - color_scheme="blue", - variant="solid", - size="2", + color_scheme="blue", size="2", ), _ok_callout(UsersState.info_ok, "Informations mises à jour."), _err_callout(UsersState.info_error), - spacing="3", - align="center", - flex_wrap="wrap", + spacing="3", align="center", flex_wrap="wrap", ), - spacing="3", + spacing="3", width="100%", + ) + + +def _class_chip(classe: rx.Var) -> rx.Component: + """Pill rouge pour une classe sélectionnée.""" + return rx.flex( + rx.text(classe, size="1", color="white", font_weight="500"), + rx.icon( + "x", size=12, color="white", + cursor="pointer", + on_click=UsersState.toggle_class(classe).stop_propagation, + ), + align="center", + gap="0.25rem", + padding="0.15rem 0.4rem 0.15rem 0.6rem", + background_color="var(--blue-9)", + border_radius="9999px", + flex_shrink="0", + ) + + +def _class_option(classe: rx.Var) -> rx.Component: + is_checked = UsersState.edit_classes.contains(classe) + return rx.box( + rx.flex( + rx.cond( + is_checked, + rx.icon("check", size=14, color="var(--blue-9)"), + rx.box(width="14px", height="14px"), + ), + rx.text(classe, size="2"), + align="center", + gap="0.5rem", + ), + padding="0.45rem 0.75rem", + cursor="pointer", + on_click=UsersState.toggle_class(classe), + _hover={"background_color": "var(--gray-3)"}, width="100%", ) -def _edit_panel_password() -> rx.Component: +def _classes_multi_select() -> rx.Component: + return rx.popover.root( + rx.popover.trigger( + rx.box( + rx.flex( + rx.cond( + UsersState.edit_classes.length() == 0, + rx.text( + "Sélectionner les classes autorisées…", + color="var(--gray-9)", size="2", + ), + rx.foreach(UsersState.edit_classes, _class_chip), + ), + wrap="wrap", + gap="0.3rem", + flex="1", + min_height="28px", + align="center", + ), + rx.icon("chevron-down", size=18, color="var(--gray-9)"), + display="flex", + align_items="center", + gap="0.5rem", + padding="0.45rem 0.6rem", + border="1px solid var(--gray-7)", + border_radius="6px", + background_color="white", + cursor="pointer", + width="100%", + ), + ), + rx.popover.content( + rx.vstack( + rx.input( + placeholder="Rechercher une classe…", + value=UsersState.classes_search, + on_change=UsersState.set_classes_search, + size="2", + width="100%", + auto_focus=True, + ), + rx.flex( + rx.button( + "Tout cocher", + on_click=UsersState.select_all_classes, + size="1", variant="soft", color_scheme="blue", + ), + rx.button( + "Tout décocher", + on_click=UsersState.clear_all_classes, + size="1", variant="soft", color_scheme="gray", + ), + gap="0.4rem", + ), + rx.cond( + UsersState.filtered_all_classes.length() > 0, + rx.box( + rx.foreach(UsersState.filtered_all_classes, _class_option), + max_height="280px", + overflow_y="auto", + width="100%", + ), + rx.box( + rx.text("Aucun résultat", size="2", color="var(--gray-9)"), + padding="0.5rem 0.75rem", + ), + ), + spacing="2", + width="100%", + ), + min_width="320px", + max_width="500px", + padding="0.5rem", + ), + open=UsersState.classes_open, + on_open_change=UsersState.set_classes_open, + ) + + +def _edit_panel_access() -> rx.Component: return rx.vstack( - rx.text("Changer le mot de passe", weight="bold", size="3"), - rx.hstack( - rx.vstack( - _label("Nouveau mot de passe"), - rx.input( - value=UsersState.pwd_new, - on_change=UsersState.set_pwd_new, - type="password", - placeholder="••••••••", - width="100%", + rx.text("Accès aux classes", weight="bold", size="3"), + rx.cond( + UsersState.edit_role == "admin", + rx.callout.root( + rx.callout.icon(rx.icon("shield-check", size=16)), + rx.callout.text( + "Les administrateurs voient toutes les classes — la restriction " + "ne s'applique pas pour ce rôle." ), - spacing="1", flex="1", min_width="0", width="100%", + color_scheme="violet", variant="soft", size="1", ), rx.vstack( - _label("Confirmer"), - rx.input( - value=UsersState.pwd_confirm, - on_change=UsersState.set_pwd_confirm, - type="password", - placeholder="••••••••", - width="100%", + rx.flex( + rx.switch( + checked=UsersState.edit_restrict, + on_change=UsersState.toggle_restrict, + size="2", + ), + rx.text( + "Limiter aux classes sélectionnées", + size="2", color="var(--gray-12)", + ), + gap="0.65rem", align="center", ), - spacing="1", flex="1", min_width="0", width="100%", + rx.cond( + UsersState.edit_restrict, + rx.vstack( + _classes_multi_select(), + rx.cond( + UsersState.edit_classes.length() == 0, + rx.callout.root( + rx.callout.icon(rx.icon("triangle-alert", size=16)), + rx.callout.text( + "Aucune classe sélectionnée — l'utilisateur ne verra aucune donnée." + ), + color_scheme="orange", variant="soft", size="1", + ), + rx.text( + UsersState.edit_classes.length(), + " classe(s) autorisée(s)", + size="1", color="var(--gray-10)", + ), + ), + spacing="2", width="100%", + ), + rx.text( + "L'utilisateur voit toutes les classes.", + size="1", color="var(--gray-10)", + ), + ), + rx.hstack( + rx.button( + rx.icon("save", size=16), + "Enregistrer l'accès", + on_click=UsersState.save_access, + color_scheme="blue", size="2", + ), + _ok_callout(UsersState.access_ok, "Accès mis à jour."), + _err_callout(UsersState.access_error), + spacing="3", align="center", flex_wrap="wrap", + ), + spacing="3", width="100%", ), - spacing="4", width="100%", flex_wrap="wrap", ), - rx.hstack( - rx.button( - rx.icon("lock", size=16), - "Enregistrer le mot de passe", - on_click=UsersState.save_password, - color_scheme="blue", - variant="solid", - size="2", + spacing="3", width="100%", + ) + + +def _edit_panel_password_info() -> rx.Component: + """Note explicative remplaçant le formulaire de mot de passe.""" + return rx.vstack( + rx.text("Mot de passe", weight="bold", size="3"), + rx.callout.root( + rx.callout.icon(rx.icon("info", size=16)), + rx.callout.text( + "Vous ne pouvez pas modifier directement le mot de passe d'un utilisateur. " + "Utilisez le bouton « Reset mdp » dans la liste — l'utilisateur recevra " + "un email avec un lien pour définir un nouveau mot de passe." ), - _ok_callout(UsersState.pwd_ok, "Mot de passe mis à jour."), - _err_callout(UsersState.pwd_error), - spacing="3", - align="center", - flex_wrap="wrap", + color_scheme="blue", variant="soft", size="1", ), - spacing="3", - width="100%", + spacing="2", width="100%", ) @@ -619,10 +924,7 @@ def _edit_panel_totp() -> rx.Component: rx.badge("Non configuré", color_scheme="gray", variant="soft"), ), ), - spacing="2", - align="center", - flex_wrap="wrap", - width="100%", + spacing="2", align="center", flex_wrap="wrap", width="100%", ), rx.cond( ~UsersState.edit_has_totp & ~UsersState.totp_ok, @@ -636,60 +938,45 @@ def _edit_panel_totp() -> rx.Component: UsersState.edit_has_totp & ~UsersState.totp_ok, rx.button( rx.icon("rotate-ccw", size=16), - "Réinitialiser l'authentificateur 2FA", + "Réinitialiser le 2FA", on_click=UsersState.reset_totp, - color_scheme="orange", - variant="outline", - size="2", + color_scheme="orange", variant="outline", size="2", ), rx.fragment(), ), - spacing="3", - width="100%", + spacing="3", width="100%", ) -def _edit_panel(is_admin_editing_other: bool) -> rx.Component: +def _edit_panel() -> rx.Component: return rx.cond( UsersState.edit_target != "", rx.box( rx.vstack( rx.hstack( rx.text( - rx.cond( - UsersState.edit_target == UsersState.username, - "Mon profil", - rx.el.span("Modifier — ", style={"font_weight": "normal"}), - ), - weight="bold", - size="3", - ), - rx.cond( - UsersState.edit_target != UsersState.username, - rx.text(UsersState.edit_target, size="3", color="var(--gray-10)"), - rx.fragment(), + "Modifier — ", UsersState.edit_target, + weight="bold", size="3", ), rx.spacer(), rx.button( rx.icon("x", size=14), on_click=UsersState.close_edit, - variant="ghost", - color_scheme="gray", - size="1", + variant="ghost", color_scheme="gray", size="1", ), - width="100%", - align="center", + width="100%", align="center", ), rx.divider(), _edit_panel_avatar(), rx.divider(), - _edit_panel_info(is_admin_editing_other), + _edit_panel_info(), rx.divider(), - _edit_panel_password(), + _edit_panel_access(), + rx.divider(), + _edit_panel_password_info(), rx.divider(), _edit_panel_totp(), - spacing="4", - width="100%", + spacing="4", width="100%", ), padding="1.25rem", background_color="var(--blue-2)", @@ -707,6 +994,11 @@ def _add_user_section() -> rx.Component: return rx.box( rx.vstack( rx.text("Ajouter un utilisateur", size="4", weight="bold"), + rx.text( + "À la création, l'utilisateur reçoit un email avec un lien pour " + "définir son mot de passe (valide 7 jours).", + size="1", color="var(--gray-11)", + ), rx.divider(), rx.hstack( rx.vstack( @@ -720,7 +1012,7 @@ def _add_user_section() -> rx.Component: spacing="1", flex="1", min_width="0", width="100%", ), rx.vstack( - _label("Prénom / Nom affiché"), + _label("Nom affiché"), rx.input( value=UsersState.new_name, on_change=UsersState.set_new_name, @@ -733,7 +1025,7 @@ def _add_user_section() -> rx.Component: ), rx.hstack( rx.vstack( - _label("Email (optionnel)"), + _label("Email"), rx.input( value=UsersState.new_email, on_change=UsersState.set_new_email, @@ -758,105 +1050,18 @@ def _add_user_section() -> rx.Component: ), spacing="4", width="100%", flex_wrap="wrap", ), - rx.hstack( - rx.vstack( - _label("Mot de passe"), - rx.input( - value=UsersState.new_pwd1, - on_change=UsersState.set_new_pwd1, - type="password", - placeholder="••••••••", - width="100%", - ), - spacing="1", flex="1", min_width="0", width="100%", - ), - rx.vstack( - _label("Confirmer le mot de passe"), - rx.input( - value=UsersState.new_pwd2, - on_change=UsersState.set_new_pwd2, - type="password", - placeholder="••••••••", - width="100%", - ), - spacing="1", flex="1", min_width="0", width="100%", - ), - spacing="4", width="100%", flex_wrap="wrap", - ), rx.hstack( rx.button( rx.icon("user-plus", size=16), - "Créer le compte", + "Créer le compte et envoyer le lien", on_click=UsersState.add_user, - color_scheme="blue", - variant="solid", - size="2", + color_scheme="blue", size="2", ), - _ok_callout(UsersState.new_ok, "Compte créé avec succès."), + _ok_callout(UsersState.new_ok, "Compte créé. Email envoyé."), _err_callout(UsersState.new_error), - spacing="3", - align="center", - flex_wrap="wrap", + spacing="3", align="center", flex_wrap="wrap", ), - spacing="3", - width="100%", - ), - padding="1.25rem", - background_color="white", - border_radius="8px", - border="1px solid #e0e0e0", - width="100%", - ) - - -# ── Admin view ──────────────────────────────────────────────────────────────── - -def _admin_view() -> rx.Component: - is_admin_editing_other = ( - (UsersState.edit_target != "") & - (UsersState.edit_target != UsersState.username) - ) - return rx.vstack( - # User list - rx.box( - rx.vstack( - rx.text("Comptes existants", size="4", weight="bold"), - rx.divider(), - rx.foreach(UsersState.users_list, _user_row), - spacing="2", - width="100%", - ), - padding="1.25rem", - background_color="white", - border_radius="8px", - border="1px solid #e0e0e0", - width="100%", - ), - # Edit panel (shown when a user is selected) - _edit_panel(is_admin_editing_other), - # Add user - _add_user_section(), - spacing="4", - width="100%", - ) - - -# ── User view (non-admin) ───────────────────────────────────────────────────── - -def _user_view() -> rx.Component: - return rx.box( - rx.vstack( - rx.text("Mon profil", size="4", weight="bold"), - rx.divider(), - _edit_panel_avatar(), - rx.divider(), - _edit_panel_info(False), - rx.divider(), - _edit_panel_password(), - rx.divider(), - _edit_panel_totp(), - spacing="4", - width="100%", + spacing="3", width="100%", ), padding="1.25rem", background_color="white", @@ -871,16 +1076,22 @@ def _user_view() -> rx.Component: def users_page() -> rx.Component: return layout( rx.vstack( - rx.cond( - UsersState.role == "admin", - rx.heading("Gestion des utilisateurs", size="7"), - rx.heading("Mon profil", size="7"), - ), - rx.cond( - UsersState.role == "admin", - _admin_view(), - _user_view(), + rx.heading("Gestion des utilisateurs", size="7"), + rx.box( + rx.vstack( + rx.text("Comptes existants", size="4", weight="bold"), + rx.divider(), + rx.foreach(UsersState.users_list, _user_row), + spacing="2", width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", ), + _edit_panel(), + _add_user_section(), spacing="5", width="100%", max_width="860px", diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index ed8ea17..dcb421b 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -272,35 +272,84 @@ def _avatar_or_photo(size: str = "2") -> rx.Component: ) +def _user_menu_items() -> rx.Component: + """Items du dropdown : Mon profil + Déconnexion.""" + return rx.vstack( + rx.link( + rx.flex( + rx.icon("user", size=15, color=_TEXT), + rx.text("Mon profil", size="2"), + gap="0.5rem", align="center", padding="0.4rem 0.75rem", + width="100%", _hover={"background_color": _HOVER_BG}, + cursor="pointer", border_radius="4px", + ), + href="/profile", + text_decoration="none", + color="inherit", + width="100%", + ), + rx.flex( + rx.icon("log-out", size=15, color=_TEXT), + rx.text("Déconnexion", size="2"), + gap="0.5rem", align="center", padding="0.4rem 0.75rem", + width="100%", _hover={"background_color": _HOVER_BG}, + cursor="pointer", border_radius="4px", + on_click=AuthState.logout, + ), + spacing="0", width="100%", + ) + + def _user_widget(collapsed: bool = False) -> rx.Component: if collapsed: - return rx.tooltip( - rx.vstack( - _avatar_or_photo(size="2"), - rx.icon_button( - rx.icon("log-out", size=14), - on_click=AuthState.logout, - variant="ghost", size="1", cursor="pointer", + return rx.popover.root( + rx.popover.trigger( + rx.tooltip( + rx.box( + _avatar_or_photo(size="2"), + cursor="pointer", + display="flex", + justify_content="center", + width="100%", + ), + content=AuthState.name, + side="right", ), - spacing="2", align="center", width="100%", ), - content=AuthState.name, - side="right", + rx.popover.content( + _user_menu_items(), + min_width="180px", + padding="0.4rem", + side="right", + align="end", + ), ) - return rx.hstack( - _avatar_or_photo(size="2"), - rx.vstack( - rx.text(AuthState.name, size="2", font_weight="600", - color=_TEXT, white_space="nowrap", overflow="hidden"), - rx.text(AuthState.role, size="1", color=_TEXT_MUTED), - spacing="0", align="start", overflow="hidden", flex="1", + return rx.popover.root( + rx.popover.trigger( + rx.hstack( + _avatar_or_photo(size="2"), + rx.vstack( + rx.text(AuthState.name, size="2", font_weight="600", + color=_TEXT, white_space="nowrap", overflow="hidden"), + rx.text(AuthState.role, size="1", color=_TEXT_MUTED), + spacing="0", align="start", overflow="hidden", flex="1", + ), + rx.icon("chevron-up", size=14, color=_TEXT_MUTED), + spacing="2", align="center", width="100%", overflow="hidden", + cursor="pointer", + padding="0.25rem 0.5rem", + border_radius="6px", + _hover={"background_color": _HOVER_BG}, + class_name="smooth-transition", + ), ), - rx.icon_button( - rx.icon("log-out", size=14), - on_click=AuthState.logout, - variant="ghost", size="1", cursor="pointer", + rx.popover.content( + _user_menu_items(), + min_width="200px", + padding="0.4rem", + side="top", + align="end", ), - spacing="2", align="center", width="100%", overflow="hidden", ) diff --git a/src/email_sender.py b/src/email_sender.py index 0dc5bc2..f37346e 100644 --- a/src/email_sender.py +++ b/src/email_sender.py @@ -63,11 +63,16 @@ def send_email( subject: str, body: str, attachments: "list[tuple[bytes, str]] | None" = None, + body_html: str | None = None, ) -> None: """Envoie un email avec pièces jointes PDF optionnelles. smtp_login : identifiant d'authentification SMTP (peut différer de l'expéditeur). smtp_sender : adresse expéditeur, format 'Nom ' ou 'email'. + body : version texte (toujours requise pour fallback). + body_html : version HTML optionnelle. Si fournie, l'email est envoyé en + multipart/alternative (les clients modernes affichent le HTML, + les anciens le texte brut). attachments : liste de (pdf_bytes, filename). Lève une exception en cas d'échec (SMTPException, OSError). """ @@ -77,7 +82,15 @@ def send_email( msg["From"] = f"{_from_name} <{_from_email}>" if _from_name else _from_email msg["To"] = to_email msg["Subject"] = subject - msg.attach(MIMEText(body, "plain", "utf-8")) + + if body_html: + # Encapsule texte+HTML dans un alternative pour que le client choisisse. + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(body, "plain", "utf-8")) + alt.attach(MIMEText(body_html, "html", "utf-8")) + msg.attach(alt) + else: + msg.attach(MIMEText(body, "plain", "utf-8")) for pdf_bytes, pdf_filename in (attachments or []): part = MIMEApplication(pdf_bytes, _subtype="pdf") diff --git a/src/password_emails.py b/src/password_emails.py new file mode 100644 index 0000000..893dbe8 --- /dev/null +++ b/src/password_emails.py @@ -0,0 +1,128 @@ +"""Envoi des emails de définition / réinitialisation de mot de passe.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path +from urllib.parse import quote + +from src.email_sender import send_email + +_ROOT = Path(__file__).resolve().parent.parent +_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) +_SETTINGS_PATH = _DATA_DIR / "settings.json" +_TEMPLATES_DIR = _DATA_DIR / "email_templates" + + +def _load_settings() -> dict: + if _SETTINGS_PATH.exists(): + try: + return json.loads(_SETTINGS_PATH.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + + +def _read_template(name: str) -> str: + path = _TEMPLATES_DIR / name + if not path.exists(): + raise FileNotFoundError( + f"Template manquant : {path}. Créez data/email_templates/{name}." + ) + return path.read_text(encoding="utf-8") + + +def _read_optional_template(name: str) -> str | None: + path = _TEMPLATES_DIR / name + if not path.exists(): + return None + return path.read_text(encoding="utf-8") + + +def _format_expiry(dt: datetime) -> str: + return dt.strftime("%d.%m.%Y à %H:%M") + + +def _format_ttl_human(dt: datetime) -> str: + """Renvoie 'dans X h' ou 'dans X jours'.""" + delta = dt - datetime.now() + total_seconds = max(0, int(delta.total_seconds())) + if total_seconds < 3600: + mins = total_seconds // 60 + return f"valable {mins} minute{'s' if mins > 1 else ''}" + hours = total_seconds // 3600 + if hours < 48: + return f"valable {hours} heure{'s' if hours > 1 else ''}" + days = hours // 24 + return f"valable {days} jour{'s' if days > 1 else ''}" + + +def send_password_email( + *, + kind: str, # "welcome" | "reset" + username: str, + name: str, + email: str, + token: str, + expires_at: datetime, +) -> None: + """Envoie un email de définition (kind='welcome') ou de reset (kind='reset'). + + Lève une exception en cas d'erreur (SMTP, template manquant, base_url manquante). + """ + if kind not in ("welcome", "reset"): + raise ValueError(f"kind must be 'welcome' or 'reset', got {kind!r}") + if not email or "@" not in email: + raise ValueError(f"Email invalide pour {username!r}") + + settings = _load_settings() + base_url = (settings.get("app_base_url") or "").rstrip("/") + if not base_url: + raise ValueError( + "URL de base manquante. Configurez `app_base_url` dans Paramètres." + ) + + smtp_host = settings.get("smtp_host") + smtp_port = int(settings.get("smtp_port") or 587) + smtp_login = settings.get("smtp_login") + smtp_password = settings.get("smtp_password") + smtp_sender = settings.get("smtp_sender") + if not (smtp_host and smtp_login and smtp_password and smtp_sender): + raise ValueError( + "Configuration SMTP incomplète. Vérifiez Paramètres → SMTP." + ) + + link = f"{base_url}/password-set?token={quote(token)}" + vars_ = { + "name": name, + "username": username, + "link": link, + "expiry": _format_expiry(expires_at), + "ttl_human": _format_ttl_human(expires_at), + } + template_text_name = "welcome.txt" if kind == "welcome" else "reset.txt" + template_html_name = "welcome.html" if kind == "welcome" else "reset.html" + body_text = _read_template(template_text_name).format_map(vars_) + html_raw = _read_optional_template(template_html_name) + body_html = html_raw.format_map(vars_) if html_raw else None + + subject = ( + "EPTM Dashboard — Définissez votre mot de passe" + if kind == "welcome" + else "EPTM Dashboard — Réinitialisation de votre mot de passe" + ) + + send_email( + smtp_host=smtp_host, + smtp_port=smtp_port, + smtp_login=smtp_login, + smtp_password=smtp_password, + smtp_sender=smtp_sender, + to_email=email, + subject=subject, + body=body_text, + body_html=body_html, + attachments=None, + ) diff --git a/src/password_tokens.py b/src/password_tokens.py new file mode 100644 index 0000000..61302db --- /dev/null +++ b/src/password_tokens.py @@ -0,0 +1,126 @@ +"""Tokens à usage unique pour la définition / réinitialisation de mot de passe. + +Stockage : data/password_tokens.json (JSON) + +Format : +{ + "": { + "username": "prof.demo", + "type": "set" | "reset", + "created_at": "2026-05-10T18:30:00", + "expires_at": "2026-05-17T18:30:00" + } +} +""" + +from __future__ import annotations + +import json +import os +import secrets +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional + +_ROOT = Path(__file__).resolve().parent.parent +_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) +TOKENS_FILE = _DATA_DIR / "password_tokens.json" + +# Durées de validité +TTL_SET = timedelta(days=7) # Création de compte +TTL_RESET = timedelta(hours=24) # Réinitialisation + + +def _load() -> dict: + if not TOKENS_FILE.exists(): + return {} + try: + return json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _save(data: dict) -> None: + TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True) + TOKENS_FILE.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + +def _purge_expired(data: dict) -> dict: + """Retourne une nouvelle dict sans les tokens expirés.""" + now = datetime.now() + out = {} + for tok, info in data.items(): + try: + exp = datetime.fromisoformat(info["expires_at"]) + except Exception: + continue + if exp > now: + out[tok] = info + return out + + +def _purge_for_user(data: dict, username: str) -> dict: + """Supprime tous les tokens existants pour un utilisateur.""" + return {t: i for t, i in data.items() if i.get("username") != username} + + +def create_token(username: str, kind: str = "set") -> tuple[str, datetime]: + """Crée un nouveau token. Tout token précédent du même utilisateur est révoqué. + + Renvoie (token_str, expires_at). + """ + if kind not in ("set", "reset"): + raise ValueError(f"Unknown token kind: {kind!r}") + + token = secrets.token_urlsafe(32) + now = datetime.now() + expires_at = now + (TTL_SET if kind == "set" else TTL_RESET) + + data = _load() + data = _purge_expired(data) + data = _purge_for_user(data, username) + data[token] = { + "username": username, + "type": kind, + "created_at": now.isoformat(timespec="seconds"), + "expires_at": expires_at.isoformat(timespec="seconds"), + } + _save(data) + return token, expires_at + + +def validate_token(token: str) -> Optional[dict]: + """Renvoie {username, type, expires_at} si le token est valide, sinon None.""" + if not token: + return None + data = _load() + info = data.get(token) + if not info: + return None + try: + exp = datetime.fromisoformat(info["expires_at"]) + except Exception: + return None + if exp <= datetime.now(): + # Token expiré : on en profite pour purger + data = _purge_expired(data) + _save(data) + return None + return info + + +def consume_token(token: str) -> None: + """Supprime le token (après usage).""" + data = _load() + if token in data: + del data[token] + _save(data) + + +def revoke_all_for_user(username: str) -> None: + """Révoque tous les tokens d'un utilisateur (par exemple lors de la suppression).""" + data = _load() + data = _purge_for_user(data, username) + _save(data) diff --git a/src/user_access.py b/src/user_access.py new file mode 100644 index 0000000..16274c4 --- /dev/null +++ b/src/user_access.py @@ -0,0 +1,60 @@ +"""Gestion des droits d'accès aux classes par utilisateur. + +Lecture du fichier `data/auth.yaml`. Les admins ont toujours accès à tout +(le champ `allowed_classes` est ignoré pour eux). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +import yaml + +_ROOT = Path(__file__).resolve().parent.parent +_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) +AUTH_FILE = _DATA_DIR / "auth.yaml" + + +def _load_user(username: str) -> Optional[dict]: + if not AUTH_FILE.exists(): + return None + with open(AUTH_FILE, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + return cfg.get("credentials", {}).get("usernames", {}).get(username) + + +def get_allowed_classes(username: str) -> Optional[list[str]]: + """Retourne la liste des classes autorisées pour l'utilisateur. + + - None : aucune restriction (admin, ou champ vide / absent) + - [] : restriction explicite à zéro classe (= ne voit rien) + - [...] : restreint à ces classes + """ + user = _load_user(username) + if not user: + return [] + if user.get("role") == "admin": + return None + allowed = user.get("allowed_classes") + if allowed is None: + return None + # `allowed_classes: []` (présent mais vide) signifie « aucun accès » + return list(allowed) + + +def is_class_allowed(username: str, classe: str) -> bool: + """True si l'utilisateur peut voir cette classe.""" + allowed = get_allowed_classes(username) + if allowed is None: + return True + return classe in allowed + + +def filter_classes(username: str, classes: list[str]) -> list[str]: + """Filtre une liste de classes selon les droits de l'utilisateur.""" + allowed = get_allowed_classes(username) + if allowed is None: + return list(classes) + return [c for c in classes if c in allowed]