diff --git a/assets/avatars/julbal.jpg b/assets/avatars/julbal.jpg new file mode 100644 index 0000000..954a0ca Binary files /dev/null and b/assets/avatars/julbal.jpg differ diff --git a/assets/default_avatar.svg b/assets/default_avatar.svg new file mode 100644 index 0000000..4500477 --- /dev/null +++ b/assets/default_avatar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/data/auth.yaml b/data/auth.yaml index 1385f74..f5637d2 100644 --- a/data/auth.yaml +++ b/data/auth.yaml @@ -5,12 +5,13 @@ cookie: credentials: usernames: julbal: + avatar_url: /avatars/julbal.jpg?t=1778400300 email: julien.balet@edu.vs.ch name: Julien Balet password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi role: admin smtp_password: 17acdfd671d8ab - totp_secret: A572MSDZMOK7WIJD52GXXJXPHN5SB6ZS + totp_secret: null test: email: julien@balet-vs.ch name: test diff --git a/data/class_href_cache.json b/data/class_href_cache.json index 1b220c3..96f56df 100644 --- a/data/class_href_cache.json +++ b/data/class_href_cache.json @@ -1,5 +1,5 @@ { - "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=84a4e84c-3566-42da-a8c9-3c00687182ff", - "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f90a41d2-e507-4687-890a-48c454da583c", - "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=38bbf90d-51da-406e-a2af-4d5f8f5958bd" + "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=38bbf90d-51da-406e-a2af-4d5f8f5958bd", + "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=687fa97d-1032-4078-94ae-1899fc1e6014", + "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8cb48a35-290c-4488-b98c-437d2c9186a6" } \ No newline at end of file diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index 47d00ec..93462b6 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -6,8 +6,8 @@ from .pages.fiche import fiche_page, FicheState from .pages.classe import classe_page, ClasseState from .pages.escada import escada_page, EscadaState from .pages.logs import logs_page, LogsState -from .pages.users import users_page -from .pages.params import params_page +from .pages.users import users_page, UsersState +from .pages.params import params_page, ParamsState TITLE = "EPTM Dashboard" @@ -30,5 +30,5 @@ app.add_page(fiche_page, route="/fiche", on_load=[AuthState.check_auth, app.add_page(classe_page, route="/classe", on_load=[AuthState.check_auth, ClasseState.load_data], title=TITLE) app.add_page(escada_page, route="/escada", on_load=[AuthState.check_auth, EscadaState.load_data], title=TITLE) app.add_page(logs_page, route="/logs", on_load=[AuthState.check_auth, LogsState.load_data], title=TITLE) -app.add_page(users_page, route="/users", on_load=AuthState.check_auth, title=TITLE) -app.add_page(params_page, route="/params", on_load=AuthState.check_auth, title=TITLE) +app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, UsersState.load_data], title=TITLE) +app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE) diff --git a/eptm_dashboard/pages/params.py b/eptm_dashboard/pages/params.py index 1d2f9b1..a9dab2c 100644 --- a/eptm_dashboard/pages/params.py +++ b/eptm_dashboard/pages/params.py @@ -1,12 +1,444 @@ -import reflex as rx -from ..sidebar import layout +import json +import os +from pathlib import Path +import reflex as rx + +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"))) +_SETTINGS_FILE = DATA_DIR / "settings.json" + +_DEFAULT_SANCTION = ( + "Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite." +) +_DEFAULT_TEMPLATE_SUBJ = "Document EPTM — {nom_complet} ({classe})" +_DEFAULT_TEMPLATE_BODY = ( + "Bonjour {prenom},\n\n" + "Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n" + "Cordialement,\nL'équipe EPTM" +) + + +def _read_settings() -> dict: + if _SETTINGS_FILE.exists(): + try: + return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + + +def _write_settings(data: dict) -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + _SETTINGS_FILE.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + +class ParamsState(AuthState): + # ── Sanction ────────────────────────────────────────────────────────────── + texte_sanction: str = "" + chef_section: str = "" + save_ok_sanction: bool = False + + # ── SMTP ────────────────────────────────────────────────────────────────── + smtp_host: str = "" + smtp_port: str = "587" + smtp_login: str = "" + smtp_password: str = "" + smtp_sender: str = "" + save_ok_smtp: bool = False + + # ── Escada ──────────────────────────────────────────────────────────────── + escada_username: str = "" + escada_password: str = "" + totp_secret: str = "" + save_ok_escada: bool = False + + # ── Template email ──────────────────────────────────────────────────────── + email_subject: str = "" + email_body: str = "" + save_ok_template: 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 + def set_smtp_host(self, v: str): self.smtp_host = v + def set_smtp_port(self, v: str): self.smtp_port = v + def set_smtp_login(self, v: str): self.smtp_login = v + def set_smtp_password(self, v: str): self.smtp_password = v + def set_smtp_sender(self, v: str): self.smtp_sender = v + def set_escada_username(self, v: str): self.escada_username = v + def set_escada_password(self, v: str): self.escada_password = v + 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 load_data(self): + if not self.authenticated: + return rx.redirect("/login") + s = _read_settings() + self.texte_sanction = s.get("texte_sanction", _DEFAULT_SANCTION) + self.chef_section = s.get("chef_section", "Patrick Rausis") + self.smtp_host = s.get("smtp_host", "smtp-relay.brevo.com") + self.smtp_port = str(s.get("smtp_port", 587)) + self.smtp_login = s.get("smtp_login", s.get("smtp_email", "")) + self.smtp_password = s.get("smtp_password", "") + self.smtp_sender = s.get("smtp_sender", "EPTM Automation ") + self.escada_username = s.get("escada_username", "") + self.escada_password = s.get("escada_password", "") + 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.save_ok_sanction = False + self.save_ok_smtp = False + self.save_ok_escada = False + self.save_ok_template = False + + def save_sanctions(self): + s = _read_settings() + s["texte_sanction"] = self.texte_sanction.strip() + s["chef_section"] = self.chef_section.strip() + _write_settings(s) + self.save_ok_sanction = True + self.save_ok_smtp = False + self.save_ok_escada = False + self.save_ok_template = False + + def save_smtp(self): + s = _read_settings() + s["smtp_host"] = self.smtp_host.strip() + try: + s["smtp_port"] = int(self.smtp_port) + except Exception: + s["smtp_port"] = 587 + s["smtp_login"] = self.smtp_login.strip() + s["smtp_password"] = self.smtp_password.strip() + s["smtp_sender"] = self.smtp_sender.strip() + s.pop("smtp_email", None) + _write_settings(s) + self.save_ok_smtp = True + self.save_ok_sanction = False + self.save_ok_escada = False + self.save_ok_template = False + + def save_escada(self): + s = _read_settings() + s["escada_username"] = self.escada_username.strip() + s["escada_password"] = self.escada_password.strip() + s["totp_secret"] = self.totp_secret.strip() + _write_settings(s) + self.save_ok_escada = True + self.save_ok_sanction = False + self.save_ok_smtp = False + self.save_ok_template = False + + def save_template(self): + s = _read_settings() + s["email_subject"] = self.email_subject + s["email_body"] = self.email_body + _write_settings(s) + self.save_ok_template = True + self.save_ok_sanction = False + self.save_ok_smtp = False + self.save_ok_escada = False + + +# ── UI helpers ──────────────────────────────────────────────────────────────── + +def _label(text: str) -> rx.Component: + return rx.text(text, size="2", weight="medium", color="var(--gray-11)") + + +def _section(title: str, *children) -> rx.Component: + return rx.box( + rx.vstack( + rx.text(title, size="4", weight="bold"), + rx.divider(), + *children, + spacing="3", + width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +def _save_ok_callout(show: bool) -> rx.Component: + return rx.cond( + show, + rx.callout.root( + rx.callout.icon(rx.icon("check", size=16)), + rx.callout.text("Paramètres enregistrés."), + color_scheme="green", + variant="soft", + size="1", + ), + rx.fragment(), + ) + + +def _input_row(*items) -> rx.Component: + return rx.hstack(*items, spacing="4", width="100%", flex_wrap="wrap") + + +def _field(label: str, input_component: rx.Component) -> rx.Component: + return rx.vstack( + _label(label), + input_component, + spacing="1", + flex="1", + min_width="200px", + width="100%", + ) + + +# ── Sections ────────────────────────────────────────────────────────────────── + +def _section_sanction() -> rx.Component: + return _section( + "Avis de sanction", + _field( + "Texte de description par défaut (champ TexteDescription)", + rx.text_area( + value=ParamsState.texte_sanction, + on_change=ParamsState.set_texte_sanction, + rows="4", + width="100%", + resize="vertical", + ), + ), + _field( + "Chef de section (CS)", + rx.input( + value=ParamsState.chef_section, + on_change=ParamsState.set_chef_section, + width="100%", + ), + ), + rx.hstack( + rx.button( + rx.icon("save", size=16), + "Enregistrer sanctions", + on_click=ParamsState.save_sanctions, + color_scheme="blue", + variant="solid", + size="2", + ), + _save_ok_callout(ParamsState.save_ok_sanction), + spacing="3", + align="center", + flex_wrap="wrap", + ), + ) + + +def _section_smtp() -> rx.Component: + return _section( + "Configuration email", + _input_row( + _field( + "Serveur SMTP", + rx.input( + value=ParamsState.smtp_host, + on_change=ParamsState.set_smtp_host, + placeholder="smtp-relay.brevo.com", + width="100%", + ), + ), + _field( + "Port", + rx.input( + value=ParamsState.smtp_port, + on_change=ParamsState.set_smtp_port, + type="number", + placeholder="587", + width="100%", + ), + ), + ), + _input_row( + _field( + "Login SMTP (authentification)", + rx.input( + value=ParamsState.smtp_login, + on_change=ParamsState.set_smtp_login, + placeholder="login@domaine.com", + width="100%", + ), + ), + _field( + "Mot de passe SMTP", + rx.input( + value=ParamsState.smtp_password, + on_change=ParamsState.set_smtp_password, + type="password", + placeholder="••••••••", + width="100%", + ), + ), + ), + _field( + "Expéditeur", + rx.input( + value=ParamsState.smtp_sender, + on_change=ParamsState.set_smtp_sender, + placeholder="EPTM Automation ", + width="100%", + ), + ), + rx.hstack( + rx.button( + rx.icon("save", size=16), + "Enregistrer configuration SMTP", + on_click=ParamsState.save_smtp, + color_scheme="blue", + variant="solid", + size="2", + ), + _save_ok_callout(ParamsState.save_ok_smtp), + spacing="3", + align="center", + flex_wrap="wrap", + ), + ) + + +def _section_escada() -> rx.Component: + return _section( + "Connexion Escada (synchronisation automatique)", + rx.text( + "Si renseignés, identifiant, mot de passe et code 2FA sont saisis automatiquement lors de la synchronisation.", + size="2", + color="var(--gray-10)", + ), + _input_row( + _field( + "Identifiant Escada", + rx.input( + value=ParamsState.escada_username, + on_change=ParamsState.set_escada_username, + placeholder="prenom.nom", + width="100%", + ), + ), + _field( + "Mot de passe Escada", + rx.input( + value=ParamsState.escada_password, + on_change=ParamsState.set_escada_password, + type="password", + placeholder="••••••••", + width="100%", + ), + ), + ), + _field( + "Clé secrète 2FA (TOTP) — Format Base32", + rx.input( + value=ParamsState.totp_secret, + on_change=ParamsState.set_totp_secret, + type="password", + placeholder="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + width="100%", + ), + ), + rx.hstack( + rx.button( + rx.icon("save", size=16), + "Enregistrer connexion Escada", + on_click=ParamsState.save_escada, + color_scheme="blue", + variant="solid", + size="2", + ), + _save_ok_callout(ParamsState.save_ok_escada), + spacing="3", + align="center", + flex_wrap="wrap", + ), + ) + + +def _section_template() -> rx.Component: + return _section( + "Template email", + rx.box( + rx.text( + "Variables disponibles : ", + rx.code("{prenom}"), ", ", + rx.code("{nom}"), ", ", + rx.code("{nom_complet}"), ", ", + rx.code("{classe}"), ", ", + rx.code("{nb_absences}"), ", ", + rx.code("{nb_excusees}"), ", ", + rx.code("{nb_non_excusees}"), ", ", + rx.code("{nb_a_traiter}"), ", ", + rx.code("{semestre}"), ", ", + rx.code("{date_du_jour}"), + size="2", + color="var(--gray-10)", + ), + padding="0.5rem 0.75rem", + background_color="var(--gray-2)", + border_radius="6px", + border="1px solid var(--gray-4)", + ), + _field( + "Objet", + rx.input( + value=ParamsState.email_subject, + on_change=ParamsState.set_email_subject, + width="100%", + ), + ), + _field( + "Corps du message", + rx.text_area( + value=ParamsState.email_body, + on_change=ParamsState.set_email_body, + rows="8", + width="100%", + resize="vertical", + font_family="monospace", + font_size="0.85rem", + ), + ), + rx.hstack( + rx.button( + rx.icon("save", size=16), + "Enregistrer template", + on_click=ParamsState.save_template, + color_scheme="blue", + variant="solid", + size="2", + ), + _save_ok_callout(ParamsState.save_ok_template), + spacing="3", + align="center", + flex_wrap="wrap", + ), + ) + + +# ── Page ────────────────────────────────────────────────────────────────────── def params_page() -> rx.Component: return layout( rx.vstack( rx.heading("Paramètres", size="7"), - rx.text("Page en cours de migration..."), - spacing="4", + _section_sanction(), + _section_smtp(), + _section_escada(), + _section_template(), + spacing="5", + width="100%", + max_width="860px", ) ) diff --git a/eptm_dashboard/pages/users.py b/eptm_dashboard/pages/users.py index b75818c..1b1cb61 100644 --- a/eptm_dashboard/pages/users.py +++ b/eptm_dashboard/pages/users.py @@ -1,12 +1,888 @@ -import reflex as rx -from ..sidebar import layout +import os +import time +from pathlib import Path +import bcrypt +import yaml +import reflex as rx + +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" + + +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 UsersState(AuthState): + users_list: list[dict] = [] + + # Edit panel + edit_target: str = "" + edit_name: str = "" + edit_email: str = "" + edit_role: str = "user" + 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 + + # Avatar + edit_avatar_url: str = "" + upload_ok: bool = False + + # 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 + + # ── 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 + + # ── Helpers ─────────────────────────────────────────────────────────────── + def _refresh_list(self): + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + self.users_list = [ + { + "username": uname, + "name": udata.get("name", uname), + "email": udata.get("email", ""), + "role": udata.get("role", "user"), + "has_totp": bool(udata.get("totp_secret")), + } + for uname, udata in users.items() + ] + + def _populate_edit(self, uname: str): + cfg = _load_auth() + users = cfg.get("credentials", {}).get("usernames", {}) + udata = users.get(uname, {}) + self.edit_target = uname + self.edit_name = udata.get("name", "") + self.edit_email = udata.get("email", "") + self.edit_role = udata.get("role", "user") + self.edit_has_totp = bool(udata.get("totp_secret")) + self.edit_avatar_url = udata.get("avatar_url", "") + 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 + + # ── 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) + + def select_user(self, uname: str): + if self.edit_target == uname: + self.edit_target = "" + else: + self._populate_edit(uname) + + def close_edit(self): + self.edit_target = "" + + def save_info(self): + self.info_error = "" + self.info_ok = False + if not self.edit_name.strip(): + self.info_error = "Le nom affiché ne peut pas être vide." + return + cfg = _load_auth() + users = cfg["credentials"]["usernames"] + uname = self.edit_target + if uname not in users: + self.info_error = "Utilisateur introuvable." + return + users[uname]["name"] = self.edit_name.strip() + users[uname]["email"] = self.edit_email.strip() + if self.role == "admin" and uname != self.username: + users[uname]["role"] = self.edit_role + _save_auth(cfg) + self.info_ok = True + 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." + 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() + _save_auth(cfg) + self.pwd_new = "" + self.pwd_confirm = "" + self.pwd_ok = True + + def reset_totp(self): + cfg = _load_auth() + users = cfg["credentials"]["usernames"] + users[self.edit_target]["totp_secret"] = None + _save_auth(cfg) + self._populate_edit(self.edit_target) + self._refresh_list() + self.totp_ok = True + + 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.edit_target + 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) + if uname == self.username: + self.photo_url = url + self.upload_ok = True + self.edit_avatar_url = url + self._refresh_list() + + def remove_avatar(self): + uname = self.edit_target + 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) + if uname == self.username: + self.photo_url = "" + self.edit_avatar_url = "" + self.upload_ok = False + self._refresh_list() + + def delete_user(self, uname: str): + if uname == self.username: + return + cfg = _load_auth() + users = cfg["credentials"]["usernames"] + if uname in users: + del users[uname] + _save_auth(cfg) + if self.edit_target == uname: + self.edit_target = "" + self._refresh_list() + + def add_user(self): + self.new_error = "" + self.new_ok = False + uname = self.new_uname.strip().lower() + name = self.new_name.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 errs: + self.new_error = " — ".join(errs) + return + cfg = _load_auth() + users = cfg["credentials"]["usernames"] + if uname in users: + self.new_error = f"L'identifiant « {uname} » est déjà utilisé." + return + users[uname] = { + "email": self.new_email.strip(), + "name": name, + "role": self.new_role, + "password": bcrypt.hashpw(self.new_pwd1.encode(), bcrypt.gensalt(12)).decode(), + "totp_secret": None, + } + _save_auth(cfg) + 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() + + +# ── UI helpers ──────────────────────────────────────────────────────────────── + +def _label(text: str) -> rx.Component: + return rx.text(text, size="2", weight="medium", color="var(--gray-11)") + + +def _ok_callout(show: bool, text: str) -> rx.Component: + return rx.cond( + show, + rx.callout.root( + rx.callout.icon(rx.icon("check", size=16)), + rx.callout.text(text), + color_scheme="green", + variant="soft", + size="1", + ), + rx.fragment(), + ) + + +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.text(msg), + color_scheme="red", + variant="soft", + size="1", + ), + rx.fragment(), + ) + + +def _role_badge(role: str) -> rx.Component: + return rx.cond( + role == "admin", + rx.badge("Admin", color_scheme="violet", variant="soft", size="1"), + rx.badge("User", color_scheme="gray", variant="soft", size="1"), + ) + + +def _totp_badge(has_totp: bool) -> rx.Component: + return rx.cond( + has_totp, + rx.badge("2FA actif", color_scheme="green", variant="soft", size="1"), + rx.badge("2FA —", color_scheme="orange", variant="soft", size="1"), + ) + + +# ── User table row ──────────────────────────────────────────────────────────── + +def _delete_dialog(user: dict) -> rx.Component: + return rx.alert_dialog.root( + rx.alert_dialog.trigger( + rx.button( + rx.icon("trash-2", size=14), + "Supprimer", + color_scheme="red", + variant="outline", + size="1", + ), + ), + rx.alert_dialog.content( + rx.alert_dialog.title("Supprimer le compte"), + rx.alert_dialog.description( + rx.text("Supprimer définitivement le compte de ", rx.text.strong(user["name"]), " ?"), + ), + rx.hstack( + rx.alert_dialog.cancel( + rx.button("Annuler", variant="soft", color_scheme="gray"), + ), + rx.alert_dialog.action( + rx.button( + "Supprimer", + color_scheme="red", + on_click=UsersState.delete_user(user["username"]), + ), + ), + spacing="3", + justify="end", + margin_top="1rem", + ), + max_width="400px", + ), + ) + + +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", + overflow="hidden", text_overflow="ellipsis", white_space="nowrap"), + _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", + ), + 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", + ), + # Droite : actions uniquement + rx.hstack( + rx.button( + rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)), + rx.cond(is_selected, "Fermer", "Éditer"), + on_click=UsersState.select_user(user["username"]), + variant=rx.cond(is_selected, "solid", "outline"), + color_scheme="blue", + size="1", + ), + rx.cond( + is_me, + rx.fragment(), + _delete_dialog(user), + ), + spacing="2", + align="center", + flex_shrink="0", + ), + align="center", + justify="between", + width="100%", + padding="0.65rem 0.75rem", + overflow="hidden", + ), + 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", + ) + + +# ── Edit panel ──────────────────────────────────────────────────────────────── + +def _edit_panel_avatar() -> rx.Component: + has_photo = UsersState.edit_avatar_url != "" + 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", + ), + rx.image( + src="/default_avatar.svg", + width="64px", + height="64px", + border_radius="50%", + flex_shrink="0", + ), + ), + 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="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%", + ), + rx.cond( + has_photo, + rx.button( + rx.icon("trash-2", size=14), + "Supprimer", + on_click=UsersState.remove_avatar, + 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="4", + align="center", + width="100%", + flex_wrap="wrap", + ), + spacing="3", + width="100%", + ) + + +def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component: + return rx.vstack( + rx.text("Informations du compte", weight="bold", size="3"), + rx.hstack( + rx.vstack( + _label("Nom affiché"), + rx.input( + value=UsersState.edit_name, + on_change=UsersState.set_edit_name, + width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + rx.vstack( + _label("Email"), + rx.input( + value=UsersState.edit_email, + on_change=UsersState.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.cond( + is_admin_editing_other, + rx.vstack( + _label("Rôle"), + rx.select.root( + rx.select.trigger(width="100%"), + rx.select.content( + rx.select.item("Utilisateur", value="user"), + rx.select.item("Administrateur", value="admin"), + ), + value=UsersState.edit_role, + on_change=UsersState.set_edit_role, + width="100%", + ), + spacing="1", + width="100%", + ), + rx.fragment(), + ), + rx.hstack( + rx.button( + rx.icon("save", size=16), + "Mettre à jour", + on_click=UsersState.save_info, + color_scheme="blue", + variant="solid", + size="2", + ), + _ok_callout(UsersState.info_ok, "Informations mises à jour."), + _err_callout(UsersState.info_error), + spacing="3", + align="center", + flex_wrap="wrap", + ), + spacing="3", + width="100%", + ) + + +def _edit_panel_password() -> 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%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + rx.vstack( + _label("Confirmer"), + rx.input( + value=UsersState.pwd_confirm, + on_change=UsersState.set_pwd_confirm, + 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("lock", size=16), + "Enregistrer le mot de passe", + on_click=UsersState.save_password, + color_scheme="blue", + variant="solid", + size="2", + ), + _ok_callout(UsersState.pwd_ok, "Mot de passe mis à jour."), + _err_callout(UsersState.pwd_error), + spacing="3", + align="center", + flex_wrap="wrap", + ), + spacing="3", + width="100%", + ) + + +def _edit_panel_totp() -> rx.Component: + return rx.vstack( + rx.text("Authentification à 2 facteurs", weight="bold", size="3"), + rx.hstack( + rx.text("Statut :", size="2", flex_shrink="0"), + rx.cond( + UsersState.totp_ok, + rx.badge("Réinitialisé", color_scheme="orange", variant="soft"), + rx.cond( + UsersState.edit_has_totp, + rx.badge("Actif", color_scheme="green", variant="soft"), + rx.badge("Non configuré", color_scheme="gray", variant="soft"), + ), + ), + spacing="2", + align="center", + flex_wrap="wrap", + width="100%", + ), + rx.cond( + ~UsersState.edit_has_totp & ~UsersState.totp_ok, + rx.text( + "Un QR code sera demandé à la prochaine connexion.", + size="1", color="var(--gray-10)", + ), + rx.fragment(), + ), + rx.cond( + UsersState.edit_has_totp & ~UsersState.totp_ok, + rx.button( + rx.icon("rotate-ccw", size=16), + "Réinitialiser l'authentificateur 2FA", + on_click=UsersState.reset_totp, + color_scheme="orange", + variant="outline", + size="2", + ), + rx.fragment(), + ), + spacing="3", + width="100%", + ) + + +def _edit_panel(is_admin_editing_other: bool) -> 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(), + ), + rx.spacer(), + rx.button( + rx.icon("x", size=14), + on_click=UsersState.close_edit, + variant="ghost", + color_scheme="gray", + size="1", + ), + width="100%", + align="center", + ), + rx.divider(), + _edit_panel_avatar(), + rx.divider(), + _edit_panel_info(is_admin_editing_other), + rx.divider(), + _edit_panel_password(), + rx.divider(), + _edit_panel_totp(), + spacing="4", + width="100%", + ), + padding="1.25rem", + background_color="var(--blue-2)", + border_radius="8px", + border="1px solid var(--blue-6)", + width="100%", + ), + rx.fragment(), + ) + + +# ── Add user form ───────────────────────────────────────────────────────────── + +def _add_user_section() -> rx.Component: + return rx.box( + rx.vstack( + rx.text("Ajouter un utilisateur", size="4", weight="bold"), + rx.divider(), + rx.hstack( + rx.vstack( + _label("Identifiant de connexion"), + rx.input( + value=UsersState.new_uname, + on_change=UsersState.set_new_uname, + placeholder="jean.dupont", + width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + rx.vstack( + _label("Prénom / Nom affiché"), + rx.input( + value=UsersState.new_name, + on_change=UsersState.set_new_name, + placeholder="Jean Dupont", + width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + spacing="4", width="100%", flex_wrap="wrap", + ), + rx.hstack( + rx.vstack( + _label("Email (optionnel)"), + rx.input( + value=UsersState.new_email, + on_change=UsersState.set_new_email, + placeholder="jean.dupont@edu.vs.ch", + width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + rx.vstack( + _label("Rôle"), + rx.select.root( + rx.select.trigger(width="100%"), + rx.select.content( + rx.select.item("Utilisateur", value="user"), + rx.select.item("Administrateur", value="admin"), + ), + value=UsersState.new_role, + on_change=UsersState.set_new_role, + width="100%", + ), + spacing="1", flex="1", min_width="0", width="100%", + ), + 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", + on_click=UsersState.add_user, + color_scheme="blue", + variant="solid", + size="2", + ), + _ok_callout(UsersState.new_ok, "Compte créé avec succès."), + _err_callout(UsersState.new_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%", + ) + + +# ── 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%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +# ── Page ────────────────────────────────────────────────────────────────────── def users_page() -> rx.Component: return layout( rx.vstack( - rx.heading("Utilisateurs", size="7"), - rx.text("Page en cours de migration..."), - spacing="4", + 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(), + ), + spacing="5", + width="100%", + max_width="860px", ) ) diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index 57afd2b..d83cefa 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -165,12 +165,34 @@ def _admin_section(mobile: bool = False) -> rx.Component: ) +def _avatar_or_photo(size: str = "2") -> rx.Component: + img_size = "32px" if size == "2" else "28px" + return rx.cond( + AuthState.photo_url != "", + rx.image( + src=AuthState.photo_url, + width=img_size, + height=img_size, + border_radius="50%", + object_fit="cover", + border="1.5px solid var(--gray-5)", + flex_shrink="0", + ), + rx.image( + src="/default_avatar.svg", + width=img_size, + height=img_size, + border_radius="50%", + flex_shrink="0", + ), + ) + + def _user_widget(collapsed: bool = False) -> rx.Component: if collapsed: return rx.tooltip( rx.vstack( - rx.avatar(fallback=AuthState.name_initials, size="2", - color_scheme="ruby", radius="full"), + _avatar_or_photo(size="2"), rx.icon_button( rx.icon("log-out", size=14), on_click=AuthState.logout, @@ -182,11 +204,10 @@ def _user_widget(collapsed: bool = False) -> rx.Component: side="right", ) return rx.hstack( - rx.avatar(fallback=AuthState.name_initials, size="2", - color_scheme="ruby", radius="full"), + _avatar_or_photo(size="2"), rx.vstack( rx.text(AuthState.name, size="2", font_weight="600", - color="#f3f4f6", white_space="nowrap", overflow="hidden"), + color=_TEXT, white_space="nowrap", overflow="hidden"), rx.text(AuthState.role, size="1", color=_TEXT_MUTED), spacing="0", align="start", overflow="hidden", flex="1", ), @@ -363,6 +384,12 @@ def layout(content: rx.Component) -> rx.Component: f"calc(100% - {RAIL_W})", f"calc(100% - {FULL_W})", ), + max_width=rx.cond( + AuthState.sidebar_collapsed, + f"calc(100% - {RAIL_W})", + f"calc(100% - {FULL_W})", + ), + overflow_x="hidden", transition="margin-left 0.22s ease, width 0.22s ease", box_sizing="border-box", ), diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index 8d61b28..74c261f 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -10,9 +10,10 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data")) class AuthState(rx.State): # Persisted in browser localStorage (survives hot reload / container restart). # Note: client-side trustable only because re-validated against auth.yaml in check_auth. - username: str = rx.LocalStorage("", sync=True) - name: str = rx.LocalStorage("", sync=True) - role: str = rx.LocalStorage("user", sync=True) + username: str = rx.LocalStorage("", sync=True) + name: str = rx.LocalStorage("", sync=True) + role: str = rx.LocalStorage("user", sync=True) + photo_url: str = rx.LocalStorage("", sync=True) # In-memory only (login form, transient UI state) login_user: str = "" @@ -70,6 +71,7 @@ class AuthState(rx.State): if self.username not in users: self._clear_session() return rx.redirect("/login") + self.photo_url = users[self.username].get("avatar_url", "") def handle_login(self, form_data: dict | None = None): self.login_error = "" @@ -84,6 +86,7 @@ class AuthState(rx.State): self.username = self.login_user self.name = user.get("name", self.login_user) self.role = user.get("role", "user") + self.photo_url = user.get("avatar_url", "") self.login_pass = "" return rx.redirect("/accueil") self.login_error = "Identifiant ou mot de passe incorrect" @@ -97,6 +100,7 @@ class AuthState(rx.State): self.username = "" self.name = "" self.role = "user" + self.photo_url = "" self.login_user = "" self.login_pass = "" self.login_error = ""