eptm_dashboard/eptm_dashboard/pages/users.py

1099 lines
40 KiB
Python

import os
import sys
import time
from pathlib import Path
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
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
# 2FA
edit_has_totp: bool = False
totp_ok: bool = False
# Avatar
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_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_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_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):
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", "")
# 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.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")
if self.role != "admin":
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:
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 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 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.edit_restrict:
users[uname]["allowed_classes"] = list(self.edit_classes)
else:
users[uname].pop("allowed_classes", None)
_save_auth(cfg)
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()
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
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:
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)
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):
self.new_error = ""
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 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.setdefault("credentials", {}).setdefault("usernames", {})
if uname in users:
self.new_error = f"L'identifiant « {uname} » est déjà utilisé."
return
users[uname] = {
"email": email,
"name": name,
"role": self.new_role,
"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_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 ────────────────────────────────────────────────────────────────
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("triangle-alert", 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"),
)
# ── Dialogs ───────────────────────────────────────────────────────────────────
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 _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(
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", flex_wrap="wrap",
),
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",
),
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",
),
_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%",
)
# ── 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(
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,
),
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."),
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() -> rx.Component:
is_other = UsersState.edit_target != UsersState.username
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_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", 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 _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 _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="var(--surface)",
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("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."
),
color_scheme="violet", variant="soft", size="1",
),
rx.vstack(
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",
),
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="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."
),
color_scheme="blue", variant="soft", size="1",
),
spacing="2", 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 le 2FA",
on_click=UsersState.reset_totp,
color_scheme="orange", variant="outline", size="2",
),
rx.fragment(),
),
spacing="3", width="100%",
)
def _edit_panel() -> rx.Component:
return rx.cond(
UsersState.edit_target != "",
rx.box(
rx.vstack(
rx.hstack(
rx.text(
"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",
),
width="100%", align="center",
),
rx.divider(),
_edit_panel_avatar(),
rx.divider(),
_edit_panel_info(),
rx.divider(),
_edit_panel_access(),
rx.divider(),
_edit_panel_password_info(),
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.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(
_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("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"),
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.button(
rx.icon("user-plus", size=16),
"Créer le compte et envoyer le lien",
on_click=UsersState.add_user,
color_scheme="blue", size="2",
),
_ok_callout(UsersState.new_ok, "Compte créé. Email envoyé."),
_err_callout(UsersState.new_error),
spacing="3", align="center", flex_wrap="wrap",
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
# ── Page ──────────────────────────────────────────────────────────────────────
def users_page() -> rx.Component:
return layout(
rx.vstack(
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="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
_edit_panel(),
_add_user_section(),
spacing="5",
width="100%",
max_width="860px",
)
)