Refonte fichier users

This commit is contained in:
2025-11-08 08:36:41 +01:00
parent 18c08d5c84
commit bd27b02a11
3 changed files with 127 additions and 202 deletions

Binary file not shown.

View File

@@ -1 +1 @@
2.40 2.41

View File

@@ -1,11 +1,12 @@
# app_users.py — création + modification de champs (wide + onglets + grille) # Streamlit app
import os import os
import re
from datetime import date, datetime from datetime import date, datetime
import re
import bcrypt import bcrypt
import mysql.connector import mysql.connector
from mysql.connector import errorcode from mysql.connector import pooling, errorcode
import pandas as pd
import streamlit as st import streamlit as st
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -14,108 +15,110 @@ from dotenv import load_dotenv
# ----------------------- # -----------------------
def require_login(): def require_login():
st.markdown( st.markdown(
""" "<style>div.block-container{padding-top:2rem;}</style>",
<style> unsafe_allow_html=True,
body {
background-color: #f2f2f2;
}
</style>
""",
unsafe_allow_html=True
) )
load_dotenv() load_dotenv()
admin_user = os.getenv("ADMIN_USER") admin_user = os.getenv("ADMIN_USER")
admin_hash = os.getenv("ADMIN_PASS_HASH") admin_hash = os.getenv("ADMIN_PASS_HASH")
if not admin_user or not admin_hash:
if not admin_hash: st.error("Variables ADMIN_USER et/ou ADMIN_PASS_HASH manquantes dans .env")
st.error("ADMIN_PASS_HASH manquant dans .env")
st.stop() st.stop()
if "auth_ok" not in st.session_state: if "auth_ok" not in st.session_state:
st.session_state.auth_ok = False st.session_state.auth_ok = False
if not st.session_state.auth_ok: if not st.session_state.auth_ok:
st.markdown("<h2 style='text-align:center;'>🔐 Accès restreint</h2>", unsafe_allow_html=True) col1, col2, col3 = st.columns([1, 2, 1])
with col2:
# conteneur centré st.header("🔐 Accès restreint")
login_left, login_center, login_right = st.columns([1, 2, 1]) u = st.text_input("Utilisateur")
with login_center: p = st.text_input("Mot de passe", type="password")
st.markdown(
"""
<div style='border:1px solid #ddd; border-radius:12px; padding:2rem; background:#fafafa;'>
""",
unsafe_allow_html=True
)
u = st.text_input("Utilisateur", key="login_user")
p = st.text_input("Mot de passe", type="password", key="login_pass")
if st.button("Se connecter", use_container_width=True): if st.button("Se connecter", use_container_width=True):
if u == admin_user and bcrypt.checkpw(p.encode(), admin_hash.encode()): try:
ok = (u == admin_user) and bcrypt.checkpw(p.encode(), admin_hash.encode())
except Exception:
ok = False
if ok:
st.session_state.auth_ok = True st.session_state.auth_ok = True
st.rerun() st.rerun()
else: else:
st.error("Identifiants invalides") st.error("Identifiants invalides.")
st.markdown("</div>", unsafe_allow_html=True)
st.stop() st.stop()
require_login() require_login()
# ----------------------- # -----------------------
# Connexion MySQL # Connexion MySQL via pool
# ----------------------- # -----------------------
@st.cache_resource @st.cache_resource
def get_connection(): def get_pool():
load_dotenv() load_dotenv()
host = os.getenv("DB_HOST") host = os.getenv("DB_HOST")
port = int(os.getenv("MYSQL_PORT", "3306")) port = int(os.getenv("MYSQL_PORT", "3306"))
user = os.getenv("DB_USER") user = os.getenv("DB_USER")
pwd = os.getenv("DB_PASSWORD") pwd = os.getenv("DB_PASSWORD")
db = os.getenv("DB_NAME") db = os.getenv("DB_NAME")
missing = [k for k, v in { missing = [k for k, v in {
"MYSQL_USER": user, "MYSQL_PASSWORD": pwd, "MYSQL_HOST": host, "MYSQL_PORT": port, "MYSQL_DATABASE": db "DB_HOST": host, "MYSQL_PORT": port, "DB_USER": user, "DB_PASSWORD": pwd, "DB_NAME": db
}.items() if v in (None, "")] }.items() if v in (None, "")]
if missing: if missing:
raise RuntimeError(f"Variables manquantes dans .env : {', '.join(missing)}") raise RuntimeError(f"Variables manquantes dans .env : {', '.join(missing)}")
return pooling.MySQLConnectionPool(
return mysql.connector.connect( pool_name="users_pool",
host=host, port=port, user=user, password=pwd, database=db, autocommit=True pool_size=5,
pool_reset_session=True,
host=host, port=port, user=user, password=pwd, database=db,
autocommit=True
) )
pool = get_pool()
# ----------------------- # -----------------------
# Utilitaires # Helpers SQL + validations
# ----------------------- # -----------------------
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
PHONE_RE = re.compile(r"\d{10,14}")
def normalize_phone(phone: str | None) -> str | None: def normalize_phone(p: str|None) -> str|None:
if not phone: if not p:
return None return None
return re.sub(r"[^\d+]", "", phone) digits = re.sub(r"\D", "", p)
return digits if PHONE_RE.match(digits) else None
def to_sql_date(d: date | str) -> str: def to_sql_date(d: date | str | None) -> str | None:
if isinstance(d, date): if d is None:
return d.strftime("%Y-%m-%d") return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y"): if isinstance(d, str):
try: try:
return datetime.strptime(d, fmt).strftime("%Y-%m-%d") d = datetime.fromisoformat(d).date()
except ValueError: except Exception:
continue return None
raise ValueError("Date invalide (attendu: YYYY-MM-DD ou JJ/MM/AAAA)") return d.strftime("%Y-%m-%d")
def hash_password(plain: str, rounds: int = 12) -> str: def hash_password(plain: str, rounds: int = 12) -> str:
salt = bcrypt.gensalt(rounds=rounds) salt = bcrypt.gensalt(rounds=rounds)
return bcrypt.hashpw(plain.encode("utf-8"), salt).decode("utf-8") return bcrypt.hashpw(plain.encode("utf-8"), salt).decode("utf-8")
def user_exists(cursor, username: str, email: str) -> bool: def user_exists(cur, username: str, email: str) -> bool:
cursor.execute( cur.execute(
"SELECT COUNT(*) FROM Utilisateurs WHERE NomUtilisateur=%s OR email=%s", "SELECT COUNT(*) FROM Utilisateurs WHERE NomUtilisateur=%s OR email=%s",
(username, email), (username, email),
) )
(count,) = cursor.fetchone() (count,) = cur.fetchone()
return count > 0 return count > 0
def list_users(cnx, limit: int = 500):
sql = """
SELECT NomUtilisateur, Nom_complet, Site, DateExpiration, Telephone, email
FROM Utilisateurs
ORDER BY NomUtilisateur
LIMIT %s
"""
with cnx.cursor(dictionary=True) as cur:
cur.execute(sql, (limit,))
return cur.fetchall()
def insert_user(cnx, username, full_name, site, password, expires, phone, email, store_plain=False): def insert_user(cnx, username, full_name, site, password, expires, phone, email, store_plain=False):
if not EMAIL_RE.match(email): if not EMAIL_RE.match(email):
raise ValueError("Email invalide.") raise ValueError("Email invalide.")
@@ -140,32 +143,18 @@ def insert_user(cnx, username, full_name, site, password, expires, phone, email,
""" """
INSERT INTO Utilisateurs INSERT INTO Utilisateurs
(NomUtilisateur, Nom_complet, Site, MotDePasse, MotDePasseHash, DateExpiration, Telephone, email) (NomUtilisateur, Nom_complet, Site, MotDePasse, MotDePasseHash, DateExpiration, Telephone, email)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s) VALUES (%s,%s,%s,NULL,%s,%s,%s,%s)
""", """,
(username, full_name, site, None, pwd_hash, exp_sql, phone_norm, email), (username, full_name, site, pwd_hash, exp_sql, phone_norm, email),
) )
return pwd_hash return pwd_hash
def list_users(cnx, limit=200): def get_user_details(cnx, username: str):
with cnx.cursor(dictionary=True) as cur: with cnx.cursor(dictionary=True) as cur:
cur.execute( cur.execute(
""" """
SELECT NomUtilisateur, Nom_complet, Site, DateExpiration, Telephone, email SELECT NomUtilisateur, Nom_complet, Site, DateExpiration, Telephone, email
FROM Utilisateurs FROM Utilisateurs WHERE NomUtilisateur=%s
ORDER BY NomUtilisateur ASC
LIMIT %s
""",
(limit,),
)
return cur.fetchall()
def get_user(cnx, username: str):
with cnx.cursor(dictionary=True) as cur:
cur.execute(
"""
SELECT NomUtilisateur, Nom_complet, Site, DateExpiration, Telephone, email
FROM Utilisateurs
WHERE NomUtilisateur=%s
""", """,
(username,), (username,),
) )
@@ -175,8 +164,8 @@ def update_field(cnx, username: str, field: str, value):
allowed = { allowed = {
"Nom_complet": "Nom_complet", "Nom_complet": "Nom_complet",
"Site": "Site", "Site": "Site",
"Telephone": "Telephone",
"DateExpiration": "DateExpiration", "DateExpiration": "DateExpiration",
"Telephone": "Telephone",
"email": "email", "email": "email",
} }
if field not in allowed: if field not in allowed:
@@ -210,81 +199,24 @@ def update_password(cnx, username: str, new_password: str):
# UI # UI
# ----------------------- # -----------------------
st.set_page_config(page_title="Acces.Utilisateurs", page_icon="👤", layout="wide") st.set_page_config(page_title="Acces.Utilisateurs", page_icon="👤", layout="wide")
st.title("Gestion des utilisateurs")
# CSS doux tab_list, tab_create, tab_edit, tab_security = st.tabs(["Liste", "Créer", "Modifier", "Sécurité"])
st.markdown(
"""
<style>
.block-container {padding-top: 1.25rem; padding-bottom: 2rem; max-width: 1400px;}
button[kind="primary"], .stButton>button {height: 2.6rem;}
.stForm {border: 1px solid #eee; padding: 1rem; border-radius: 12px;}
.soft-card {border:1px solid #eee; padding:0.75rem 1rem; border-radius:12px; background:#fafafa;}
</style>
""",
unsafe_allow_html=True
)
st.title("👤 Acces.Utilisateurs")
# Connexion DB (badge)
try:
cnx = get_connection()
st.caption("Connexion MySQL via `.env` : **OK** ✅")
except Exception as e:
st.error(f"Connexion MySQL impossible : {e}")
st.stop()
# Bouton Sortie / Exit
hdr_l, hdr_r = st.columns([1, 0.18])
with hdr_r:
if st.button("🔒 SORTIE / EXIT", use_container_width=True, help="Se déconnecter et verrouiller l'accès"):
st.session_state.auth_ok = False
st.rerun()
# -----------------------
# Onglets
# -----------------------
tab_list, tab_create, tab_edit, tab_security = st.tabs(
["📋 Liste", "🆕 Créer", "✏️ Modifier", "🔐 Sécurité"]
)
# --- Onglet Liste --- # --- Onglet Liste ---
with tab_list: with tab_list:
st.subheader("Utilisateurs récents") st.subheader("Utilisateurs")
try:
cnx = pool.get_connection()
try: try:
import pandas as pd
rows = list_users(cnx, limit=1000) rows = list_users(cnx, limit=1000)
finally:
cnx.close()
df = pd.DataFrame(rows) df = pd.DataFrame(rows)
if not df.empty: if not df.empty:
df["DateExpiration"] = pd.to_datetime(df["DateExpiration"], errors="coerce").dt.date df["DateExpiration"] = pd.to_datetime(df["DateExpiration"], errors="coerce").dt.date
st.dataframe(df, use_container_width=True, hide_index=True)
from datetime import date as _date st.caption(f"{len(df)} utilisateur(s)")
today = _date.today()
expired_mask = df["DateExpiration"] <= today
col_a, col_b = st.columns(2)
col_a.metric("Total utilisateurs", len(df))
col_b.metric("Comptes périmés", int(expired_mask.sum()))
def style_expired(row):
if row["DateExpiration"] <= today:
return ["color: white; background-color: #d32f2f;"] * len(row)
return [""] * len(row)
styled = df.style.apply(style_expired, axis=1)
visible_rows = len(df)
row_px = 36
header_px = 48
padding_px = 24
height = min(1000, header_px + row_px * visible_rows + padding_px)
st.dataframe(
styled,
use_container_width=True,
hide_index=True,
height=height,
)
else: else:
st.info("Aucun utilisateur à afficher.") st.info("Aucun utilisateur à afficher.")
except Exception as e: except Exception as e:
@@ -306,32 +238,30 @@ with tab_create:
expires = c6.date_input("DateExpiration", value=date.today()) expires = c6.date_input("DateExpiration", value=date.today())
c7, c8 = st.columns(2) c7, c8 = st.columns(2)
password = c7.text_input("Mot de passe (saisie)", type="password") password = c7.text_input("Mot de passe", type="password")
confirm = c8.text_input("Confirmer mot de passe", type="password") password2 = c8.text_input("Confirmer", type="password")
store_plain = st.checkbox("Stocker le mot de passe en clair (déconseillé)", value=False)
store_plain = st.checkbox("Remplir aussi MotDePasse en clair (déconseillé)", value=False) submitted = st.form_submit_button("Créer l'utilisateur", use_container_width=True)
submitted = st.form_submit_button("Créer lutilisateur", use_container_width=True)
if submitted: if submitted:
if not username or not full_name or not site or not email or not password: if not username or not full_name or not site or not email:
st.error("Merci de remplir tous les champs obligatoires.") st.error("Champs requis manquants.")
elif password != confirm: elif password != password2:
st.error("Les mots de passe ne correspondent pas.") st.error("Les mots de passe ne correspondent pas.")
else: else:
try:
cnx = pool.get_connection()
try: try:
pwd_hash = insert_user( pwd_hash = insert_user(
cnx, cnx, username=username, full_name=full_name, site=site,
username=username.strip(), password=password, expires=expires, phone=phone, email=email,
full_name=full_name.strip(), store_plain=store_plain
site=site.strip(),
password=password,
expires=expires,
phone=phone.strip() if phone else None,
email=email.strip(),
store_plain=store_plain,
) )
finally:
cnx.close()
st.success("Utilisateur créé avec succès ✅") st.success("Utilisateur créé avec succès ✅")
with st.expander("Voir le hash généré (MotDePasseHash)"): st.caption("Hash (MotDePasseHash) :")
st.code(pwd_hash) st.code(pwd_hash)
except mysql.connector.Error as db_err: except mysql.connector.Error as db_err:
if db_err.errno == errorcode.ER_ACCESS_DENIED_ERROR: if db_err.errno == errorcode.ER_ACCESS_DENIED_ERROR:
@@ -344,9 +274,12 @@ with tab_create:
# --- Onglet Modifier --- # --- Onglet Modifier ---
with tab_edit: with tab_edit:
st.subheader("Modifier un utilisateur existant") st.subheader("Modifier un utilisateur existant")
try:
cnx = pool.get_connection()
try: try:
users = list_users(cnx, limit=1000) users = list_users(cnx, limit=1000)
finally:
cnx.close()
usernames = [u["NomUtilisateur"] for u in users] usernames = [u["NomUtilisateur"] for u in users]
except Exception as e: except Exception as e:
users = [] users = []
@@ -354,43 +287,27 @@ with tab_edit:
st.warning(f"Impossible de charger la liste des utilisateurs : {e}") st.warning(f"Impossible de charger la liste des utilisateurs : {e}")
top_left, top_right = st.columns([1.2, 2]) top_left, top_right = st.columns([1.2, 2])
with top_left: with top_left:
sel_user = st.selectbox("Utilisateur", usernames, placeholder="Choisir un utilisateur") sel_user = st.selectbox("Utilisateur", usernames, placeholder="Choisir un utilisateur")
field = st.selectbox( field = st.selectbox(
"Champ à modifier", "Champ à modifier",
["Nom_complet", "Site", "Telephone", "DateExpiration", "email"] ["Nom_complet", "Site", "DateExpiration", "Telephone", "email"]
)
if field == "DateExpiration":
new_value = st.date_input("Nouvelle valeur (date)", value=date.today(), key="new_date")
elif field == "Telephone":
new_value = st.text_input("Nouvelle valeur (téléphone)", key="new_tel")
else:
new_value = st.text_input("Nouvelle valeur", key="new_text")
update_btn = st.button(
"Mettre à jour le champ",
type="primary",
use_container_width=True,
disabled=not sel_user,
) )
with top_right: with top_right:
if sel_user: if field == "DateExpiration":
details = get_user(cnx, sel_user) new_value = st.date_input("Nouvelle valeur", value=date.today())
if details: else:
c1, c2, c3, c4, c5 = st.columns(5, gap="small") new_value = st.text_input("Nouvelle valeur")
c1.markdown(f"<div class='soft-card'><b>Nom</b><br>{details['Nom_complet'] or ''}</div>", unsafe_allow_html=True)
c2.markdown(f"<div class='soft-card'><b>Site</b><br>{details['Site'] or ''}</div>", unsafe_allow_html=True)
c3.markdown(f"<div class='soft-card'><b>Expire</b><br>{details['DateExpiration'] or ''}</div>", unsafe_allow_html=True)
c4.markdown(f"<div class='soft-card'><b>Tél</b><br>{details['Telephone'] or ''}</div>", unsafe_allow_html=True)
c5.markdown(f"<div class='soft-card'><b>Email</b><br>{details['email'] or ''}</div>", unsafe_allow_html=True)
update_btn = st.button("Mettre à jour", disabled=not sel_user, use_container_width=True)
if update_btn and sel_user: if update_btn and sel_user:
try:
cnx = pool.get_connection()
try: try:
update_field(cnx, sel_user, field, new_value) update_field(cnx, sel_user, field, new_value)
finally:
cnx.close()
st.success(f"{field} mis à jour pour {sel_user}") st.success(f"{field} mis à jour pour {sel_user}")
except mysql.connector.Error as db_err: except mysql.connector.Error as db_err:
st.error(f"Erreur MySQL : {db_err}") st.error(f"Erreur MySQL : {db_err}")
@@ -402,7 +319,11 @@ with tab_security:
st.subheader("Réinitialiser le mot de passe (bcrypt)") st.subheader("Réinitialiser le mot de passe (bcrypt)")
try: try:
if 'user_cache_for_pw' not in st.session_state: if 'user_cache_for_pw' not in st.session_state:
cnx = pool.get_connection()
try:
st.session_state.user_cache_for_pw = [u["NomUtilisateur"] for u in list_users(cnx, limit=1000)] st.session_state.user_cache_for_pw = [u["NomUtilisateur"] for u in list_users(cnx, limit=1000)]
finally:
cnx.close()
pw_user_list = st.session_state.user_cache_for_pw pw_user_list = st.session_state.user_cache_for_pw
except Exception: except Exception:
pw_user_list = [] pw_user_list = []
@@ -418,8 +339,12 @@ with tab_security:
elif new_pw != new_pw2: elif new_pw != new_pw2:
st.error("Les mots de passe ne correspondent pas.") st.error("Les mots de passe ne correspondent pas.")
else: else:
try:
cnx = pool.get_connection()
try: try:
h = update_password(cnx, user_pw, new_pw) h = update_password(cnx, user_pw, new_pw)
finally:
cnx.close()
st.success("Mot de passe mis à jour ✅") st.success("Mot de passe mis à jour ✅")
st.caption("Hash (MotDePasseHash) :") st.caption("Hash (MotDePasseHash) :")
st.code(h) st.code(h)