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]