avatars
This commit is contained in:
parent
096dfd727b
commit
129ca39e2d
9 changed files with 1375 additions and 25 deletions
BIN
assets/avatars/julbal.jpg
Normal file
BIN
assets/avatars/julbal.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
10
assets/default_avatar.svg
Normal file
10
assets/default_avatar.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="c">
|
||||||
|
<circle cx="50" cy="50" r="50"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<circle cx="50" cy="50" r="50" fill="#a0a0a0"/>
|
||||||
|
<circle cx="50" cy="36" r="19" fill="#ececec" clip-path="url(#c)"/>
|
||||||
|
<ellipse cx="50" cy="93" rx="34" ry="27" fill="#ececec" clip-path="url(#c)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 366 B |
|
|
@ -5,12 +5,13 @@ cookie:
|
||||||
credentials:
|
credentials:
|
||||||
usernames:
|
usernames:
|
||||||
julbal:
|
julbal:
|
||||||
|
avatar_url: /avatars/julbal.jpg?t=1778400300
|
||||||
email: julien.balet@edu.vs.ch
|
email: julien.balet@edu.vs.ch
|
||||||
name: Julien Balet
|
name: Julien Balet
|
||||||
password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi
|
password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi
|
||||||
role: admin
|
role: admin
|
||||||
smtp_password: 17acdfd671d8ab
|
smtp_password: 17acdfd671d8ab
|
||||||
totp_secret: A572MSDZMOK7WIJD52GXXJXPHN5SB6ZS
|
totp_secret: null
|
||||||
test:
|
test:
|
||||||
email: julien@balet-vs.ch
|
email: julien@balet-vs.ch
|
||||||
name: test
|
name: test
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=84a4e84c-3566-42da-a8c9-3c00687182ff",
|
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=38bbf90d-51da-406e-a2af-4d5f8f5958bd",
|
||||||
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f90a41d2-e507-4687-890a-48c454da583c",
|
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=687fa97d-1032-4078-94ae-1899fc1e6014",
|
||||||
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=38bbf90d-51da-406e-a2af-4d5f8f5958bd"
|
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8cb48a35-290c-4488-b98c-437d2c9186a6"
|
||||||
}
|
}
|
||||||
|
|
@ -6,8 +6,8 @@ from .pages.fiche import fiche_page, FicheState
|
||||||
from .pages.classe import classe_page, ClasseState
|
from .pages.classe import classe_page, ClasseState
|
||||||
from .pages.escada import escada_page, EscadaState
|
from .pages.escada import escada_page, EscadaState
|
||||||
from .pages.logs import logs_page, LogsState
|
from .pages.logs import logs_page, LogsState
|
||||||
from .pages.users import users_page
|
from .pages.users import users_page, UsersState
|
||||||
from .pages.params import params_page
|
from .pages.params import params_page, ParamsState
|
||||||
|
|
||||||
TITLE = "EPTM Dashboard"
|
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(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(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(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(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, title=TITLE)
|
app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,444 @@
|
||||||
import reflex as rx
|
import json
|
||||||
from ..sidebar import layout
|
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 <noreply@eptm-automation.ch>")
|
||||||
|
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 <noreply@eptm-automation.ch>",
|
||||||
|
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:
|
def params_page() -> rx.Component:
|
||||||
return layout(
|
return layout(
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
rx.heading("Paramètres", size="7"),
|
rx.heading("Paramètres", size="7"),
|
||||||
rx.text("Page en cours de migration..."),
|
_section_sanction(),
|
||||||
spacing="4",
|
_section_smtp(),
|
||||||
|
_section_escada(),
|
||||||
|
_section_template(),
|
||||||
|
spacing="5",
|
||||||
|
width="100%",
|
||||||
|
max_width="860px",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,888 @@
|
||||||
import reflex as rx
|
import os
|
||||||
from ..sidebar import layout
|
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:
|
def users_page() -> rx.Component:
|
||||||
return layout(
|
return layout(
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
rx.heading("Utilisateurs", size="7"),
|
rx.cond(
|
||||||
rx.text("Page en cours de migration..."),
|
UsersState.role == "admin",
|
||||||
spacing="4",
|
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",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def _user_widget(collapsed: bool = False) -> rx.Component:
|
||||||
if collapsed:
|
if collapsed:
|
||||||
return rx.tooltip(
|
return rx.tooltip(
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
rx.avatar(fallback=AuthState.name_initials, size="2",
|
_avatar_or_photo(size="2"),
|
||||||
color_scheme="ruby", radius="full"),
|
|
||||||
rx.icon_button(
|
rx.icon_button(
|
||||||
rx.icon("log-out", size=14),
|
rx.icon("log-out", size=14),
|
||||||
on_click=AuthState.logout,
|
on_click=AuthState.logout,
|
||||||
|
|
@ -182,11 +204,10 @@ def _user_widget(collapsed: bool = False) -> rx.Component:
|
||||||
side="right",
|
side="right",
|
||||||
)
|
)
|
||||||
return rx.hstack(
|
return rx.hstack(
|
||||||
rx.avatar(fallback=AuthState.name_initials, size="2",
|
_avatar_or_photo(size="2"),
|
||||||
color_scheme="ruby", radius="full"),
|
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
rx.text(AuthState.name, size="2", font_weight="600",
|
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),
|
rx.text(AuthState.role, size="1", color=_TEXT_MUTED),
|
||||||
spacing="0", align="start", overflow="hidden", flex="1",
|
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% - {RAIL_W})",
|
||||||
f"calc(100% - {FULL_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",
|
transition="margin-left 0.22s ease, width 0.22s ease",
|
||||||
box_sizing="border-box",
|
box_sizing="border-box",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
|
||||||
class AuthState(rx.State):
|
class AuthState(rx.State):
|
||||||
# Persisted in browser localStorage (survives hot reload / container restart).
|
# Persisted in browser localStorage (survives hot reload / container restart).
|
||||||
# Note: client-side trustable only because re-validated against auth.yaml in check_auth.
|
# Note: client-side trustable only because re-validated against auth.yaml in check_auth.
|
||||||
username: str = rx.LocalStorage("", sync=True)
|
username: str = rx.LocalStorage("", sync=True)
|
||||||
name: str = rx.LocalStorage("", sync=True)
|
name: str = rx.LocalStorage("", sync=True)
|
||||||
role: str = rx.LocalStorage("user", sync=True)
|
role: str = rx.LocalStorage("user", sync=True)
|
||||||
|
photo_url: str = rx.LocalStorage("", sync=True)
|
||||||
|
|
||||||
# In-memory only (login form, transient UI state)
|
# In-memory only (login form, transient UI state)
|
||||||
login_user: str = ""
|
login_user: str = ""
|
||||||
|
|
@ -70,6 +71,7 @@ class AuthState(rx.State):
|
||||||
if self.username not in users:
|
if self.username not in users:
|
||||||
self._clear_session()
|
self._clear_session()
|
||||||
return rx.redirect("/login")
|
return rx.redirect("/login")
|
||||||
|
self.photo_url = users[self.username].get("avatar_url", "")
|
||||||
|
|
||||||
def handle_login(self, form_data: dict | None = None):
|
def handle_login(self, form_data: dict | None = None):
|
||||||
self.login_error = ""
|
self.login_error = ""
|
||||||
|
|
@ -84,6 +86,7 @@ class AuthState(rx.State):
|
||||||
self.username = self.login_user
|
self.username = self.login_user
|
||||||
self.name = user.get("name", self.login_user)
|
self.name = user.get("name", self.login_user)
|
||||||
self.role = user.get("role", "user")
|
self.role = user.get("role", "user")
|
||||||
|
self.photo_url = user.get("avatar_url", "")
|
||||||
self.login_pass = ""
|
self.login_pass = ""
|
||||||
return rx.redirect("/accueil")
|
return rx.redirect("/accueil")
|
||||||
self.login_error = "Identifiant ou mot de passe incorrect"
|
self.login_error = "Identifiant ou mot de passe incorrect"
|
||||||
|
|
@ -97,6 +100,7 @@ class AuthState(rx.State):
|
||||||
self.username = ""
|
self.username = ""
|
||||||
self.name = ""
|
self.name = ""
|
||||||
self.role = "user"
|
self.role = "user"
|
||||||
|
self.photo_url = ""
|
||||||
self.login_user = ""
|
self.login_user = ""
|
||||||
self.login_pass = ""
|
self.login_pass = ""
|
||||||
self.login_error = ""
|
self.login_error = ""
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue