1099 lines
40 KiB
Python
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",
|
|
)
|
|
)
|