Proteger droit oubli et enrichir journal

This commit is contained in:
Daniel Allaire 2026-05-31 12:28:04 -04:00
parent c24b0625d3
commit ea4bab3c94
17 changed files with 246 additions and 33 deletions

View file

@ -32,3 +32,7 @@ class Membre(Base):
@property
def is_sysadmin(self) -> bool:
return (self.courriel or "").strip().lower() == "sysadmin" or bool(getattr(self, "_sysadmin_session", False))
@property
def anonymise(self) -> bool:
return (self.pin_hash or "") == "SUPPRIME" or (self.courriel or "").startswith("supprime_")

View file

@ -18,7 +18,7 @@ from app.models.reserve import Reserve
from app.models.mouvement_reserve import MouvementReserve
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
from app.services.finance import encaisse, solde_banque, solde_reserve, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
@ -143,6 +143,7 @@ async def ajuster_collecte(
if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation")
avant = snapshot(collecte, "montant", "statut")
if req.montant is not None:
collecte.montant = req.montant
await enregistrer_historique(
@ -153,6 +154,8 @@ async def ajuster_collecte(
action="collecte.modifiee",
raison="Montant de collecte ajusté avant confirmation.",
membre_id=membre.id,
avant=avant,
apres=snapshot(collecte, "montant", "statut"),
)
await db.flush()
return await _to_out(collecte, db)
@ -181,6 +184,7 @@ async def confirmer_collecte(
if collecte.saisi_par == membre.id:
raise HTTPException(status_code=400, detail="Le montant doit être confirmé par un autre membre")
avant = snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception")
collecte.confirme_par = membre.id
collecte.date_confirmation = datetime.now(timezone.utc)
@ -204,6 +208,8 @@ async def confirmer_collecte(
action="collecte.confirmee" if collecte.statut == "confirmee" else "collecte.recue",
raison="Collecte confirmée." if collecte.statut == "confirmee" else "Collecte confirmée et reçue par le trésorier.",
membre_id=membre.id,
avant=avant,
apres=snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception"),
)
await db.flush()
@ -231,6 +237,7 @@ async def accuser_reception(
if collecte.statut == "recu":
raise HTTPException(status_code=400, detail="Cette collecte a déjà été reçue")
avant = snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception")
# Si en_attente, confirme aussi
if collecte.statut == "en_attente":
collecte.confirme_par = membre.id
@ -248,6 +255,8 @@ async def accuser_reception(
action="collecte.recue",
raison="Collecte reçue par le trésorier.",
membre_id=membre.id,
avant=avant,
apres=snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception"),
)
await db.flush()
return await _to_out(collecte, db)

View file

@ -14,7 +14,7 @@ from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre
from app.models.destinataire_contribution import DestinataireContribution
from app.services.finance import position_tresorerie, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut,
@ -152,6 +152,7 @@ async def modifier_destinataire(
nom = req.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Nom requis")
avant = snapshot(dest, "nom", "actif")
dest.nom = nom
await enregistrer_historique(
db,
@ -161,6 +162,8 @@ async def modifier_destinataire(
action="destinataire.modifie",
raison="Destinataire de contribution renommé.",
membre_id=membre.id,
avant=avant,
apres=snapshot(dest, "nom", "actif"),
)
await db.flush()
return DestinataireContributionOut.model_validate(dest)
@ -179,6 +182,7 @@ async def supprimer_destinataire(
await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
avant = snapshot(dest, "nom", "actif")
dest.actif = False
await enregistrer_historique(
db,
@ -188,6 +192,8 @@ async def supprimer_destinataire(
action="destinataire.supprime",
raison="Destinataire de contribution désactivé.",
membre_id=membre.id,
avant=avant,
apres=snapshot(dest, "nom", "actif"),
)
await db.flush()
return {"message": "Destinataire supprimé"}
@ -314,6 +320,7 @@ async def confirmer_debit_contribution(
if position.solde_banque - float(envoi.montant) < position.total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
avant = snapshot(envoi, "engagee", "debitee", "methode", "montant")
envoi.debitee = True
envoi.engagee = True
db.add(transaction(membre.groupe_id, membre.id, date.today(), "contribution", "banque", envoi.montant, "credit", "envois_contributions", envoi.id, DESTINATIONS_LABELS.get(envoi.destination, envoi.destination)))
@ -325,6 +332,8 @@ async def confirmer_debit_contribution(
action="contribution.debit_confirme",
raison="Débit bancaire de la contribution confirmé.",
membre_id=membre.id,
avant=avant,
apres=snapshot(envoi, "engagee", "debitee", "methode", "montant"),
)
await db.flush()

View file

@ -12,7 +12,7 @@ from app.models.depense import Depense
from app.models.membre import Membre
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
from app.services.finance import position_tresorerie, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
@ -144,6 +144,7 @@ async def confirmer_depense(
detail="Le solde bancaire disponible est insuffisant pour engager cette dépense",
)
avant = snapshot(depense, "paye", "methode", "confirme_par", "date_confirmation")
depense.methode = methode_norm
depense.confirme_par = membre.id
depense.date_confirmation = datetime.now(timezone.utc)
@ -162,6 +163,8 @@ async def confirmer_depense(
action="depense.traitee",
raison=f"Dépense traitée par {methode_norm.lower()}.",
membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "confirme_par", "date_confirmation"),
)
await db.flush()
@ -194,6 +197,7 @@ async def confirmer_debit(
if position.solde_banque - float(depense.montant) < position.total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
avant = snapshot(depense, "paye", "methode", "date_confirmation")
depense.paye = True
depense.date_confirmation = datetime.now(timezone.utc)
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "banque", depense.montant, "credit", "depenses", depense.id, depense.quoi))
@ -205,6 +209,8 @@ async def confirmer_debit(
action="depense.debit_confirme",
raison="Débit bancaire de la dépense confirmé.",
membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "date_confirmation"),
)
await db.flush()
return await _to_out(depense, db)
@ -228,6 +234,7 @@ async def rejeter_depense(
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Seules les dépenses soumises non traitées peuvent être rejetées")
avant = snapshot(depense, "date_depense", "montant", "qui", "quoi", "paye", "methode", "preuve_nom")
if depense.preuve_nom:
chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom)
if os.path.exists(chemin):
@ -241,6 +248,7 @@ async def rejeter_depense(
action="depense.rejetee",
raison="Dépense soumise rejetée.",
membre_id=membre.id,
avant=avant,
)
await db.delete(depense)
await db.flush()
@ -302,6 +310,7 @@ async def modifier_depense(
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
avant = snapshot(depense, "paye", "methode", "quoi")
if req.paye is not None:
depense.paye = req.paye
if req.methode is not None:
@ -318,6 +327,8 @@ async def modifier_depense(
action="depense.modifiee",
raison="Dépense modifiée par le trésorier.",
membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "quoi"),
)
await db.flush()

View file

@ -10,7 +10,7 @@ from app.models.litterature import Litterature
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"])
@ -101,6 +101,7 @@ async def modifier_titre(
raise HTTPException(status_code=404, detail="Titre introuvable")
await verifier_version_historisee(db, item, version_attendue, table_cible="litterature")
avant = snapshot(item, "titre", "categorie", "prix_coutant", "seuil_commande", "code_bsg")
for field, value in req.model_dump(exclude_none=True).items():
setattr(item, field, value)
await enregistrer_historique(
@ -111,6 +112,8 @@ async def modifier_titre(
action="litterature.modifiee",
raison="Titre de littérature modifié.",
membre_id=membre.id,
avant=avant,
apres=snapshot(item, "titre", "categorie", "prix_coutant", "seuil_commande", "code_bsg"),
)
await db.flush()
return _to_out(item)

View file

@ -7,6 +7,7 @@ from app.core.database import get_db
from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin, is_sysadmin
from app.models.membre import Membre
from app.schemas.schemas import MembreCreateAdmin, MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest
from app.services.historique import enregistrer_historique
router = APIRouter(prefix="/membres", tags=["Membres"])
@ -101,8 +102,7 @@ async def liste_membres(
result = await db.execute(
select(Membre).where(
Membre.groupe_id == executif.groupe_id,
Membre.actif == True,
).order_by(Membre.prenom)
).order_by(Membre.actif.desc(), Membre.prenom)
)
return [MembreComplet.model_validate(m) for m in result.scalars().all()]
@ -164,11 +164,63 @@ async def desactiver_membre(
raise HTTPException(status_code=404, detail="Membre introuvable")
if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé")
if membre.anonymise:
raise HTTPException(status_code=400, detail="Ce membre a exercé son droit à l'oubli")
if not membre.actif:
return {"message": f"{membre.prenom} est déjà désactivé"}
membre.actif = False
await enregistrer_historique(
db,
groupe_id=executif.groupe_id,
table_cible="membres",
objet_id=membre.id,
action="desactivation_membre",
raison=f"Compte de {membre.prenom} désactivé",
membre_id=executif.id,
chemin_api=f"/membres/{membre_id}/desactiver",
avant={"actif": True},
apres={"actif": False},
)
await db.flush()
return {"message": f"{membre.prenom} a été désactivé"}
@router.patch("/{membre_id}/reactiver")
async def reactiver_membre(
membre_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""G2 — Réactiver le droit de connexion d'un membre."""
result = await db.execute(
select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id)
)
membre = result.scalar_one_or_none()
if not membre:
raise HTTPException(status_code=404, detail="Membre introuvable")
if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé")
if membre.anonymise:
raise HTTPException(status_code=400, detail="Ce membre a exercé son droit à l'oubli et ne peut pas être réactivé")
if membre.actif:
return {"message": f"{membre.prenom} est déjà actif"}
membre.actif = True
await enregistrer_historique(
db,
groupe_id=executif.groupe_id,
table_cible="membres",
objet_id=membre.id,
action="reactivation_membre",
raison=f"Compte de {membre.prenom} réactivé",
membre_id=executif.id,
chemin_api=f"/membres/{membre_id}/reactiver",
avant={"actif": False},
apres={"actif": True},
)
await db.flush()
return {"message": f"{membre.prenom} a été réactivé"}
@router.delete("/{membre_id}/oubli")
async def droit_oubli(
membre_id: str,
@ -186,16 +238,30 @@ async def droit_oubli(
raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte")
if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé")
if membre.anonymise:
return {"message": f"Données de {membre.prenom} déjà anonymisées"}
prenom_original = membre.prenom
# Anonymiser
membre.prenom = "Ancien membre"
actif_avant = membre.actif
membre.nom = None
membre.telephone = "000-000-0000"
membre.courriel = f"supprime_{membre.id}@anon.local"
membre.pin_hash = "SUPPRIME"
membre.actif = False
membre.push_sub = None
membre.notification_preferences = None
membre.date_abstinence = None
await enregistrer_historique(
db,
groupe_id=executif.groupe_id,
table_cible="membres",
objet_id=membre.id,
action="droit_oubli",
raison=f"Données personnelles de {prenom_original} anonymisées; prénom conservé pour l'historique",
membre_id=executif.id,
chemin_api=f"/membres/{membre_id}/oubli",
avant={"actif": actif_avant, "anonymise": False},
apres={"actif": False, "anonymise": True, "prenom_conserve": prenom_original},
)
await db.flush()
return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"}

View file

@ -13,7 +13,7 @@ from app.models.reserve import Reserve
from app.models.membre import Membre
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
from app.services.finance import position_tresorerie, reserve_disponible, solde_reserve
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
@ -214,6 +214,7 @@ async def modifier_reserve(
ancien_montant = await solde_reserve(reserve.id, db)
if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2):
raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement")
avant = snapshot(reserve, "nom", "note", "active", "montant")
if req.nom is not None:
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
@ -229,6 +230,8 @@ async def modifier_reserve(
action="reserve.modifiee",
raison="Réserve renommée ou note modifiée.",
membre_id=membre.id,
avant=avant,
apres=snapshot(reserve, "nom", "note", "active", "montant"),
)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le, modifie_le=reserve.modifie_le)
@ -331,6 +334,7 @@ async def supprimer_reserve(
montant = await solde_reserve(reserve.id, db)
if round(montant, 2) != 0:
raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée")
avant = snapshot(reserve, "nom", "note", "active", "montant")
reserve.active = False
await enregistrer_historique(
db,
@ -340,6 +344,8 @@ async def supprimer_reserve(
action="reserve.supprimee",
raison="Réserve supprimée après retour à zéro.",
membre_id=membre.id,
avant=avant,
apres=snapshot(reserve, "nom", "note", "active", "montant"),
)
await db.flush()
return {"message": "Réserve supprimée"}

View file

@ -16,7 +16,7 @@ from app.models.candidature import Candidature
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.propositions import PropositionCreate, PropositionOut, PropositionVote
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/propositions", tags=["Propositions et résolutions"])
@ -110,6 +110,7 @@ async def seconder_proposition(
if prop.proposeur_id == secondeur.id and not is_sysadmin(membre):
raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition")
avant = snapshot(prop, "statut", "secondeur_id")
prop.secondeur_id = secondeur.id
prop.statut = "secondee"
await enregistrer_historique(
@ -120,6 +121,8 @@ async def seconder_proposition(
action="proposition.secondee",
raison=f"Proposition secondée par {secondeur.prenom}.",
membre_id=membre.id,
avant=avant,
apres=snapshot(prop, "statut", "secondeur_id"),
)
await db.flush()
return await _to_out(prop, db)
@ -152,6 +155,7 @@ async def voter_proposition(
if req.statut == "rejetee" and prop.statut not in ("proposee", "secondee"):
raise HTTPException(status_code=400, detail="Seule une proposition en cours peut être rejetée")
avant = snapshot(prop, "statut", "numero_resolution", "date_vote", "date_adoption", "etant_donne_que", "groupe_a_decide_de", "note")
# Attribuer un numéro de résolution si adoptée
if req.statut == "adoptee":
etant_donne_que = (req.etant_donne_que or "").strip()
@ -215,6 +219,8 @@ async def voter_proposition(
action="proposition.adoptee" if req.statut == "adoptee" else "proposition.rejetee",
raison="Proposition transformée en décision du groupe." if req.statut == "adoptee" else "Proposition rejetée par le groupe.",
membre_id=executif.id,
avant=avant,
apres=snapshot(prop, "statut", "numero_resolution", "date_vote", "date_adoption", "etant_donne_que", "groupe_a_decide_de", "note"),
)
await db.flush()

View file

@ -10,7 +10,7 @@ from app.models.pv_reunion import PvReunion
from app.models.reunion import Reunion
from app.models.membre import Membre
from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"])
@ -119,6 +119,7 @@ async def modifier_pv(
raise HTTPException(status_code=404, detail="PV introuvable")
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
avant = snapshot(pv, "contenu", "decisions", "approuve")
if req.contenu is not None:
pv.contenu = req.contenu.strip()
if req.decisions is not None:
@ -133,6 +134,8 @@ async def modifier_pv(
action="pv.modifie",
raison="Procès-verbal modifié.",
membre_id=membre.id,
avant=avant,
apres=snapshot(pv, "contenu", "decisions", "approuve"),
)
await db.flush()
return await _to_out(pv, db)
@ -155,6 +158,7 @@ async def adopter_pv(
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
avant = snapshot(pv, "contenu", "decisions", "approuve")
if pv.approuve:
raise HTTPException(status_code=400, detail="PV déjà adopté")
if req.proposeur_id == req.secondeur_id:
@ -192,6 +196,8 @@ async def adopter_pv(
action="pv.adopte",
raison="Procès-verbal adopté.",
membre_id=membre.id,
avant=avant,
apres=snapshot(pv, "contenu", "decisions", "approuve"),
)
await db.flush()
return await _to_out(pv, db)

View file

@ -20,7 +20,7 @@ from app.models.litterature import Litterature
from app.models.vente_jeton import VenteJeton
from app.models.vente_litterature import VenteLitterature
from app.schemas.phase1 import ReunionCreate, ReunionOut
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
@ -248,6 +248,7 @@ async def modifier_reunion(
if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable")
await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions")
avant = snapshot(reunion, "animateur_id", "theme", "annonces", "note")
animateur = None
if req.animateur_id:
animateur = await db.get(Membre, req.animateur_id)
@ -266,6 +267,8 @@ async def modifier_reunion(
action="reunion.modifiee",
raison="Rencontre modifiée.",
membre_id=membre.id,
avant=avant,
apres=snapshot(reunion, "animateur_id", "theme", "annonces", "note"),
)
await db.flush()
return ReunionOut(

View file

@ -45,6 +45,7 @@ class MembreComplet(MembrePublic):
nom: Optional[str] = None
telephone: str
actif: bool
anonymise: bool = False
cree_le: datetime
model_config = {"from_attributes": True}

View file

@ -8,6 +8,19 @@ from app.models.historique_modification import HistoriqueModification
from app.models.membre import Membre
def snapshot(obj, *champs: str) -> dict:
"""Extrait un état JSON simple d'un objet métier."""
data = {}
for champ in champs:
valeur = getattr(obj, champ, None)
if hasattr(valeur, "isoformat"):
valeur = valeur.isoformat()
else:
valeur = str(valeur) if valeur is not None and not isinstance(valeur, (str, int, float, bool, list, dict)) else valeur
data[champ] = valeur
return data
async def enregistrer_historique(
db: AsyncSession,
*,

View file

@ -51,7 +51,9 @@ la regle est formulee selon le code et l'ecart est liste dans la section
- RM-MEM-006 - Un executif peut consulter les membres actifs du groupe.
- RM-MEM-007 - La desactivation d'un membre conserve son historique.
- RM-MEM-008 - Le droit a l'oubli anonymise le membre au lieu d'effacer les donnees relationnelles.
- RM-MEM-009 - Le droit a l'oubli retire le prenom, le nom, le telephone, la date d'abstinence et remplace le courriel par une adresse locale anonyme.
- RM-MEM-009 - Le droit a l'oubli conserve l'identifiant technique et le prenom du membre pour preserver l'historique.
- RM-MEM-010 - Le droit a l'oubli retire le nom, neutralise le telephone, la date d'abstinence, le courriel de connexion, le PIN, les preferences de notification et les abonnements push.
- RM-MEM-011 - La desactivation ou la reactivation d'un membre agit seulement sur son droit de connexion.
- RM-POS-001 - Les postes appartiennent a une categorie valide: `executif`, `service` ou `physique`.
- RM-POS-002 - Une affectation active lie un membre, un poste et une date de debut.
- RM-POS-003 - Une affectation directe creee par le module d'affectation produit une decision adoptee de type `Nomination`.

View file

@ -194,7 +194,9 @@ Pas de récupération en libre-service (par design). L'exécutif doit :
1. Exécutif ouvre **G2 — Gérer les membres**
2. Sélectionne le membre → **Droit à l'oubli**
3. Les données personnelles sont supprimées, les données historiques anonymisées
3. Le prénom et l'ID demeurent pour préserver l'historique
4. Le nom, le téléphone, l'identifiant de connexion, le PIN, les préférences de notification, les abonnements push et la date d'abstinence sont effacés ou neutralisés
5. Le compte ne peut plus être réactivé
### Restaurer la base après incident

View file

@ -463,14 +463,16 @@ Terminer un mandat conserve l'historique. Une nouvelle affectation peut ensuite
Les exécutifs peuvent gérer les membres :
- consulter les membres actifs;
- consulter les membres actifs, inactifs et anonymisés;
- créer un compte pour un membre;
- générer des invitations;
- désactiver un membre;
- désactiver ou réactiver le droit de connexion d'un membre;
- corriger certaines informations;
- utiliser les actions administratives permises.
Le compte `Sysadmin` est protégé contre la suppression.
Le droit à l'oubli est distinct de la désactivation. Il conserve l'ID et le prénom pour préserver l'historique, mais efface ou neutralise les autres renseignements personnels et empêche toute réactivation du compte.
Le compte `Sysadmin` est protégé contre la désactivation et le droit à l'oubli.
### Invitations de membres

View file

@ -69,6 +69,17 @@ export default function GererMembres() {
} catch (e) { alert(e.message); }
};
const reactiver = async (id) => {
try {
const res = await api.patch(`/membres/${id}/reactiver`);
setMsg(res.message);
setConfirmAction(null);
setDetail(null);
charger();
setTimeout(() => setMsg(''), 3000);
} catch (e) { alert(e.message); }
};
const droitOubli = async (id) => {
try {
const res = await api.request('DELETE', `/membres/${id}/oubli`);
@ -106,8 +117,15 @@ export default function GererMembres() {
setCreateLoading(false);
};
const estAnonymise = (m) => Boolean(m?.anonymise || m?.courriel?.startsWith('supprime_'));
const telephoneVisible = (m) => m?.telephone && m.telephone !== '000-000-0000';
const courrielVisible = (m) => m?.courriel && !m.courriel.startsWith('supprime_');
// Vue détail d'un membre
if (detail) {
const anonymise = estAnonymise(detail);
const membreCourant = detail.id === membre?.id;
const actionsAdministrativesPermises = !detail.is_sysadmin && !membreCourant;
return (
<div className="min-h-screen bg-gray-100">
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
@ -119,26 +137,26 @@ export default function GererMembres() {
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div><p className="text-xs text-gray-400">Prénom</p><p className="font-medium">{detail.prenom}</p></div>
<div><p className="text-xs text-gray-400">Nom</p><p className="font-medium">{detail.nom}</p></div>
<div><p className="text-xs text-gray-400">Téléphone</p><p className="font-medium">{detail.telephone || '—'}</p></div>
<div><p className="text-xs text-gray-400">Courriel</p><p className="font-medium">{detail.courriel || '—'}</p></div>
<div><p className="text-xs text-gray-400">Nom</p><p className="font-medium">{detail.nom || '—'}</p></div>
<div><p className="text-xs text-gray-400">Téléphone</p><p className="font-medium">{telephoneVisible(detail) ? detail.telephone : '—'}</p></div>
<div><p className="text-xs text-gray-400">Courriel</p><p className="font-medium">{courrielVisible(detail) ? detail.courriel : '—'}</p></div>
<div><p className="text-xs text-gray-400">Date d'abstinence</p><p className="font-medium">{detail.date_abstinence || '—'}</p></div>
<div><p className="text-xs text-gray-400">Statut</p>
<span className={`inline-block px-2 py-1 rounded-full text-xs font-bold ${detail.actif ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{detail.actif ? 'Actif' : 'Inactif'}
<span className={`inline-block px-2 py-1 rounded-full text-xs font-bold ${detail.actif ? 'bg-green-100 text-green-700' : anonymise ? 'bg-gray-100 text-gray-600' : 'bg-red-100 text-red-700'}`}>
{anonymise ? 'Anonymisé' : detail.actif ? 'Actif' : 'Inactif'}
</span>
</div>
</div>
{/* Contact rapide */}
<div className="grid grid-cols-2 gap-2">
{detail.telephone && (
{telephoneVisible(detail) && (
<a href={`tel:${detail.telephone}`}
className="p-3 bg-green-50 border border-green-200 rounded-xl text-center text-green-700 font-bold text-sm">
📞 Appeler
</a>
)}
{detail.courriel && (
{courrielVisible(detail) && (
<a href={`mailto:${detail.courriel}`}
className="p-3 bg-blue-50 border border-blue-200 rounded-xl text-center text-blue-700 font-bold text-sm">
✉️ Courriel
@ -147,20 +165,26 @@ export default function GererMembres() {
</div>
{/* Actions */}
{estSysadmin && detail.id !== membre?.id && !confirmAction && (
{estSysadmin && detail.id !== membre?.id && !anonymise && !confirmAction && (
<button onClick={() => agirComme(detail.id)}
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold text-sm">
Agir comme ce membre
</button>
)}
{detail.actif && !confirmAction && (
{actionsAdministrativesPermises && detail.actif && !confirmAction && (
<button onClick={() => setConfirmAction('desactiver')}
className="w-full p-3 bg-orange-50 border border-orange-200 text-orange-700 rounded-xl font-bold text-sm">
Désactiver ce membre
</button>
)}
{!confirmAction && (
{actionsAdministrativesPermises && !detail.actif && !anonymise && !confirmAction && (
<button onClick={() => setConfirmAction('reactiver')}
className="w-full p-3 bg-green-50 border border-green-200 text-green-700 rounded-xl font-bold text-sm">
Réactiver ce membre
</button>
)}
{actionsAdministrativesPermises && !anonymise && !confirmAction && (
<button onClick={() => setConfirmAction('oubli')}
className="w-full p-3 bg-red-50 border border-red-200 text-red-700 rounded-xl font-bold text-sm">
Droit à l'oubli (irréversible)
@ -177,11 +201,20 @@ export default function GererMembres() {
</div>
</div>
)}
{confirmAction === 'reactiver' && (
<div className="bg-green-50 rounded-2xl p-4 border border-green-200">
<p className="text-sm text-green-800 mb-3">Réactiver {detail.prenom} ? Le membre pourra de nouveau se connecter avec son identifiant et son PIN existants.</p>
<div className="flex gap-2">
<button onClick={() => reactiver(detail.id)} className="flex-1 p-3 bg-green-600 text-white rounded-xl font-bold">Confirmer</button>
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
</div>
</div>
)}
{confirmAction === 'oubli' && (
<div className="bg-red-50 rounded-2xl p-4 border border-red-200">
<p className="text-sm text-red-800 mb-3">⚠️ Cette action est irréversible. Toutes les données personnelles de {detail.prenom} seront anonymisées.</p>
<p className="text-sm text-red-800 mb-3">⚠️ Cette action est irréversible. Le prénom de {detail.prenom} demeurera pour préserver l'historique; le nom, le téléphone, l'identifiant, le PIN et la date d'abstinence seront effacés ou neutralisés.</p>
<div className="flex gap-2">
<button onClick={() => droitOubli(detail.id)} className="flex-1 p-3 bg-red-600 text-white rounded-xl font-bold">Supprimer</button>
<button onClick={() => droitOubli(detail.id)} className="flex-1 p-3 bg-red-600 text-white rounded-xl font-bold">Anonymiser</button>
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
</div>
</div>
@ -193,7 +226,7 @@ export default function GererMembres() {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des membres" sousTitre="Membres, invitations, désactivation" />
<PageHeader titre="Gestion des membres" sousTitre="Membres, invitations, accès" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium mb-2">{msg}</div>}
@ -261,7 +294,9 @@ export default function GererMembres() {
className="w-full bg-white rounded-2xl p-4 shadow-sm flex items-center justify-between active:scale-[0.98] transition">
<div>
<p className="font-bold text-gray-800">{m.prenom} {m.nom}</p>
<p className="text-xs text-gray-400">{m.telephone || m.courriel || '—'}</p>
<p className="text-xs text-gray-400">
{estAnonymise(m) ? 'Anonymisé' : m.actif ? (m.telephone || m.courriel || '—') : 'Inactif'}
</p>
</div>
<span className="text-gray-300">→</span>
</button>

View file

@ -60,6 +60,19 @@ export default function JournalActions() {
dateStyle: 'medium',
timeStyle: 'short',
});
const formatValeur = (value) => {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'boolean') return value ? 'oui' : 'non';
if (Array.isArray(value)) return `${value.length} élément(s)`;
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const changements = (avant = {}, apres = {}) => {
const cles = [...new Set([...Object.keys(avant || {}), ...Object.keys(apres || {})])];
return cles
.map((cle) => [cle, avant?.[cle], apres?.[cle]])
.filter(([, a, b]) => JSON.stringify(a) !== JSON.stringify(b));
};
const updateFiltre = (key, value) => setFiltres((f) => ({ ...f, [key]: value }));
const resetFiltres = () => {
const vides = { date_debut: '', date_fin: '', membre_id: '', table_cible: '', action: '', q: '' };
@ -136,14 +149,36 @@ export default function JournalActions() {
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-bold text-gray-800">{item.raison}</p>
<p className="text-xs text-gray-400 mt-1">{formatDate(item.cree_le)}</p>
<p className="text-xs text-gray-400 mt-1">
{formatDate(item.cree_le)} · {item.membre_prenom || item.membre_id || 'Membre inconnu'}
</p>
</div>
<span className="text-xs font-bold rounded-full bg-blue-50 text-blue-700 px-2 py-1">
{item.action}
</span>
</div>
{item.apres && changements(item.avant, item.apres).length > 0 && (
<div className="mt-3 rounded-xl bg-gray-50 p-3 text-xs text-gray-600 space-y-2">
{changements(item.avant, item.apres).map(([cle, avant, apres]) => (
<div key={cle} className="grid grid-cols-[90px_1fr] gap-2">
<span className="font-bold text-gray-500">{cle}</span>
<span>
<span className="text-red-700">{formatValeur(avant)}</span>
<span className="text-gray-400"> → </span>
<span className="text-green-700">{formatValeur(apres)}</span>
</span>
</div>
))}
</div>
)}
{item.avant && !item.apres && (
<div className="mt-3 rounded-xl bg-red-50 p-3 text-xs text-red-700 space-y-1">
{Object.entries(item.avant).map(([cle, valeur]) => (
<p key={cle}><span className="font-bold">{cle}:</span> {formatValeur(valeur)}</p>
))}
</div>
)}
<div className="mt-3 text-xs text-gray-500 space-y-1">
<p>Membre: {item.membre_prenom || item.membre_id || '—'}</p>
<p>Objet: {item.table_cible} · {item.objet_id}</p>
</div>
</div>