Ajouter acces sysadmin aux groupes

- bouton Sysadmin public avec PIN

- CRUD sysadmin des groupes

- groupe demo protege et reinitialisable
This commit is contained in:
Daniel Allaire 2026-05-31 22:48:07 -04:00
parent c4c7dc7b64
commit 1946b8741e
6 changed files with 379 additions and 12 deletions

View file

@ -2,24 +2,59 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, select from sqlalchemy import func, select
from pydantic import BaseModel
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin from app.core.security import create_token, get_current_membre, is_sysadmin, verify_pin
from app.services.admin_database import initialiser_demo, remettre_a_zero from app.services.admin_database import (
creer_groupe,
initialiser_demo,
modifier_groupe,
reinitialiser_demo,
remettre_a_zero,
supprimer_groupe,
)
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.membre import Membre from app.models.membre import Membre
from app.models.reunion import Reunion from app.models.reunion import Reunion
from app.models.historique_modification import HistoriqueModification from app.models.historique_modification import HistoriqueModification
from app.schemas.schemas import MembrePublic, TokenResponse
router = APIRouter(prefix="/admin", tags=["Administration"]) router = APIRouter(prefix="/admin", tags=["Administration"])
class SysadminLoginRequest(BaseModel):
pin: str
class GroupeAdminRequest(BaseModel):
nom: str
district: str = "87-16"
region: str = "87"
adresse_local: str | None = None
jour_reunion: str | None = "mardi"
heure_reunion: str | None = "19:30"
assemblee_affaires_ordre: str = "premiere"
format_reunion: str | None = "Discussion ouverte"
lien_aa87: str = "https://aa87.org"
async def require_sysadmin(membre=Depends(get_current_membre)): async def require_sysadmin(membre=Depends(get_current_membre)):
if not is_sysadmin(membre): if not is_sysadmin(membre):
raise HTTPException(status_code=403, detail="Réservé au sysadmin") raise HTTPException(status_code=403, detail="Réservé au sysadmin")
return membre return membre
@router.post("/sysadmin-login", response_model=TokenResponse)
async def sysadmin_login(req: SysadminLoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Membre).where(Membre.courriel == "sysadmin", Membre.actif == True))
for membre in result.scalars().all():
if verify_pin(req.pin, membre.pin_hash):
token = create_token(str(membre.id), str(membre.groupe_id), expire_jours=1)
return TokenResponse(access_token=token, membre=MembrePublic.model_validate(membre))
raise HTTPException(status_code=401, detail="PIN Sysadmin invalide")
@router.post("/reset") @router.post("/reset")
async def reset_database( async def reset_database(
_sysadmin=Depends(require_sysadmin), _sysadmin=Depends(require_sysadmin),
@ -36,11 +71,20 @@ async def demo_database(
return await initialiser_demo(db) return await initialiser_demo(db)
@router.post("/demo/reset")
async def reset_demo_database(
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
return await reinitialiser_demo(db)
@router.get("/groupes") @router.get("/groupes")
async def liste_groupes_admin( async def liste_groupes_admin(
_sysadmin=Depends(require_sysadmin), _sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
await initialiser_demo(db)
groupes = (await db.execute(select(Groupe).order_by(Groupe.nom))).scalars().all() groupes = (await db.execute(select(Groupe).order_by(Groupe.nom))).scalars().all()
out = [] out = []
for groupe in groupes: for groupe in groupes:
@ -63,9 +107,51 @@ async def liste_groupes_admin(
"heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None, "heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None,
"assemblee_affaires_ordre": groupe.assemblee_affaires_ordre, "assemblee_affaires_ordre": groupe.assemblee_affaires_ordre,
"format_reunion": groupe.format_reunion, "format_reunion": groupe.format_reunion,
"lien_aa87": groupe.lien_aa87,
"cree_le": groupe.cree_le, "cree_le": groupe.cree_le,
"membres": nb_membres, "membres": nb_membres,
"reunions": nb_reunions, "reunions": nb_reunions,
"traces": nb_traces, "traces": nb_traces,
"demo": groupe.nom == "Groupe Résilience",
"protege": groupe.nom == "Groupe Résilience",
}) })
return out return out
@router.post("/groupes")
async def creer_groupe_admin(
req: GroupeAdminRequest,
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
try:
groupe = await creer_groupe(db, req.model_dump())
return {"message": "Groupe créé", "id": str(groupe.id)}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.patch("/groupes/{groupe_id}")
async def modifier_groupe_admin(
groupe_id: str,
req: GroupeAdminRequest,
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
try:
groupe = await modifier_groupe(db, groupe_id, req.model_dump())
return {"message": "Groupe modifié", "id": str(groupe.id)}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.delete("/groupes/{groupe_id}")
async def supprimer_groupe_admin(
groupe_id: str,
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
try:
return await supprimer_groupe(db, groupe_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))

View file

@ -10,6 +10,7 @@ from app.models.membre import Membre
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.invitation import Invitation from app.models.invitation import Invitation
from app.schemas.schemas import ConnexionRequest, InscriptionRequest, TokenResponse, MembrePublic from app.schemas.schemas import ConnexionRequest, InscriptionRequest, TokenResponse, MembrePublic
from app.services.admin_database import initialiser_demo
router = APIRouter(prefix="/auth", tags=["Authentification"]) router = APIRouter(prefix="/auth", tags=["Authentification"])
@ -27,6 +28,7 @@ async def health(db: AsyncSession = Depends(get_db)):
@router.get("/groupes") @router.get("/groupes")
async def liste_groupes(db: AsyncSession = Depends(get_db)): async def liste_groupes(db: AsyncSession = Depends(get_db)):
await initialiser_demo(db)
result = await db.execute(select(Groupe).order_by(Groupe.nom)) result = await db.execute(select(Groupe).order_by(Groupe.nom))
groupes = result.scalars().all() groupes = result.scalars().all()
return [{ return [{

View file

@ -111,7 +111,7 @@ async def initialiser_demo(db: AsyncSession) -> dict:
if existant: if existant:
membres_existants = await _membres_par_courriel(db, existant.id) membres_existants = await _membres_par_courriel(db, existant.id)
return { return {
"message": "Le groupe de démonstration existe déjà. Aucune donnée existante n'a été modifiée.", "message": "Le groupe de démonstration existe déjà.",
"groupe_id": existant.id, "groupe_id": existant.id,
"membres": len(membres_existants), "membres": len(membres_existants),
"periode_mois": 12, "periode_mois": 12,
@ -120,6 +120,7 @@ async def initialiser_demo(db: AsyncSession) -> dict:
} }
groupe, sysadmin, postes, litterature = await _creer_base( groupe, sysadmin, postes, litterature = await _creer_base(
db,
nom_groupe="Groupe Résilience", nom_groupe="Groupe Résilience",
adresse_local="Salle communautaire Saint-Louis, Montréal", adresse_local="Salle communautaire Saint-Louis, Montréal",
) )
@ -139,6 +140,86 @@ async def initialiser_demo(db: AsyncSession) -> dict:
} }
async def reinitialiser_demo(db: AsyncSession) -> dict:
groupe = (await db.execute(select(Groupe).where(Groupe.nom == "Groupe Résilience"))).scalar_one_or_none()
if not groupe:
return await initialiser_demo(db)
await _vider_groupe(db, groupe.id)
sysadmin, postes, litterature = await _peupler_groupe_base(db, groupe)
membres = {sysadmin.courriel: sysadmin}
await _ajouter_membres_demo(db, groupe, membres, postes)
membres = await _membres_par_courriel(db, groupe.id)
await _ajouter_historique_demo(db, groupe, membres, postes, litterature)
await db.flush()
return {
"message": "Données fictives du groupe de démonstration réinitialisées",
"groupe_id": groupe.id,
"membres": len(membres),
"periode_mois": 12,
"rencontres": 52,
"assemblees": 12,
}
async def creer_groupe(db: AsyncSession, data: dict) -> Groupe:
nom = (data.get("nom") or "").strip()
if not nom:
raise ValueError("Nom requis")
if nom == "Groupe Résilience":
raise ValueError("Ce nom est réservé au groupe de démonstration")
groupe = Groupe(
nom=nom,
district=(data.get("district") or "87-16").strip(),
region=(data.get("region") or "87").strip(),
adresse_local=(data.get("adresse_local") or "").strip() or None,
jour_reunion=(data.get("jour_reunion") or "mardi").strip() or None,
heure_reunion=_parse_heure(data.get("heure_reunion")),
assemblee_affaires_ordre=(data.get("assemblee_affaires_ordre") or "premiere").strip(),
format_reunion=(data.get("format_reunion") or "Discussion ouverte").strip() or None,
lien_aa87=(data.get("lien_aa87") or "https://aa87.org").strip(),
)
db.add(groupe)
await db.flush()
await _peupler_groupe_base(db, groupe)
await db.flush()
return groupe
async def modifier_groupe(db: AsyncSession, groupe_id, data: dict) -> Groupe:
groupe = await db.get(Groupe, groupe_id)
if not groupe:
raise ValueError("Groupe introuvable")
nom = (data.get("nom") or groupe.nom).strip()
if groupe.nom == "Groupe Résilience" and nom != "Groupe Résilience":
raise ValueError("Le groupe de démonstration ne peut pas être renommé")
if nom == "Groupe Résilience" and groupe.nom != "Groupe Résilience":
raise ValueError("Ce nom est réservé au groupe de démonstration")
groupe.nom = nom
for attr in ["district", "region", "adresse_local", "jour_reunion", "assemblee_affaires_ordre", "format_reunion", "lien_aa87"]:
if attr in data:
valeur = data.get(attr)
setattr(groupe, attr, valeur.strip() if isinstance(valeur, str) and valeur.strip() else None)
if "heure_reunion" in data:
groupe.heure_reunion = _parse_heure(data.get("heure_reunion"))
await db.flush()
return groupe
async def supprimer_groupe(db: AsyncSession, groupe_id) -> dict:
groupe = await db.get(Groupe, groupe_id)
if not groupe:
raise ValueError("Groupe introuvable")
if groupe.nom == "Groupe Résilience":
raise ValueError("Le groupe de démonstration est protégé")
nb_groupes = (await db.execute(select(Groupe))).scalars().all()
if len(nb_groupes) <= 1:
raise ValueError("Il doit rester au moins un groupe")
await _vider_groupe(db, groupe.id)
await db.delete(groupe)
await db.flush()
return {"message": f"Groupe {groupe.nom} supprimé"}
async def _creer_base( async def _creer_base(
db: AsyncSession, db: AsyncSession,
nom_groupe: str = "Groupe Méditation", nom_groupe: str = "Groupe Méditation",
@ -157,7 +238,11 @@ async def _creer_base(
) )
db.add(groupe) db.add(groupe)
await db.flush() await db.flush()
sysadmin, postes, litterature = await _peupler_groupe_base(db, groupe)
return groupe, sysadmin, postes, litterature
async def _peupler_groupe_base(db: AsyncSession, groupe: Groupe):
postes = {} postes = {}
for nom, categorie, description, prerequis, ordre, modules in POSTES: for nom, categorie, description, prerequis, ordre, modules in POSTES:
poste = Poste( poste = Poste(
@ -206,7 +291,7 @@ async def _creer_base(
db.add(DestinataireContribution(groupe_id=groupe.id, nom="Bureau des services généraux", protege=False, actif=True)) db.add(DestinataireContribution(groupe_id=groupe.id, nom="Bureau des services généraux", protege=False, actif=True))
for destination, pct in [("district", 50), ("region", 20), ("bsg", 20), ("intergroupe", 10)]: for destination, pct in [("district", 50), ("region", 20), ("bsg", 20), ("intergroupe", 10)]:
db.add(ConfigRepartition(groupe_id=groupe.id, destination=destination, pourcentage=pct, actif_depuis=date.today().replace(day=1))) db.add(ConfigRepartition(groupe_id=groupe.id, destination=destination, pourcentage=pct, actif_depuis=date.today().replace(day=1)))
return groupe, sysadmin, postes, litterature return sysadmin, postes, litterature
async def _ajouter_membres_demo(db: AsyncSession, groupe: Groupe, membres: dict, postes: dict) -> None: async def _ajouter_membres_demo(db: AsyncSession, groupe: Groupe, membres: dict, postes: dict) -> None:
@ -587,6 +672,54 @@ def _moment(jour: date, heure: int = 20, minute: int = 0) -> datetime:
return datetime.combine(jour, time(heure, minute)).replace(tzinfo=timezone.utc) return datetime.combine(jour, time(heure, minute)).replace(tzinfo=timezone.utc)
def _parse_heure(valeur) -> time | None:
if isinstance(valeur, time):
return valeur
if not valeur:
return None
heures, minutes = str(valeur).split(":", 1)
return time(int(heures), int(minutes[:2]))
async def _vider_groupe(db: AsyncSession, groupe_id) -> None:
params = {"gid": str(groupe_id)}
await db.execute(text("DELETE FROM presences WHERE reunion_id IN (SELECT id FROM reunions WHERE groupe_id = :gid)"), params)
await db.execute(text("DELETE FROM pv_reunions WHERE reunion_id IN (SELECT id FROM reunions WHERE groupe_id = :gid)"), params)
await db.execute(text("DELETE FROM postes_modules WHERE poste_id IN (SELECT id FROM postes WHERE groupe_id = :gid)"), params)
await db.execute(text("UPDATE candidatures SET proposition_id = NULL WHERE groupe_id = :gid"), params)
await db.execute(text("UPDATE propositions SET candidature_id = NULL WHERE groupe_id = :gid"), params)
for table in [
"journal_actions",
"historique_modifications",
"push_subscriptions",
"mouvements_reserves",
"transactions_comptables",
"rapports_adoptes",
"rapports_rsg",
"ventes_litterature",
"ventes_jetons",
"jetons",
"envois_contributions",
"destinataires_contributions",
"config_repartition",
"operations_bancaires",
"reserves",
"evenements",
"depenses",
"collectes",
"propositions",
"candidatures",
"rotations",
"affectations",
"litterature",
"invitations",
"reunions",
"postes",
"membres",
]:
await db.execute(text(f"DELETE FROM {table} WHERE groupe_id = :gid"), params)
async def _trace( async def _trace(
db: AsyncSession, db: AsyncSession,
groupe: Groupe, groupe: Groupe,

View file

@ -17,6 +17,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from app.core.database import engine, Base, async_session from app.core.database import engine, Base, async_session
from app.core.security import hash_pin from app.core.security import hash_pin
from app.services.admin_database import initialiser_demo
# Importer TOUS les modèles pour que create_all crée toutes les tables # Importer TOUS les modèles pour que create_all crée toutes les tables
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.membre import Membre from app.models.membre import Membre
@ -200,6 +201,9 @@ async def seed():
)) ))
print(" ✓ Destinataire de contribution : District 87-16") print(" ✓ Destinataire de contribution : District 87-16")
demo = await initialiser_demo(db)
print(f" ✓ Groupe de démonstration : {demo['message']}")
await db.commit() await db.commit()
print(f"\n═══ Initialisation terminée ═══") print(f"\n═══ Initialisation terminée ═══")

View file

@ -12,6 +12,8 @@ export default function GestionGroupes() {
const { loadGroupes, logout } = useStore(); const { loadGroupes, logout } = useStore();
const [groupes, setGroupes] = useState([]); const [groupes, setGroupes] = useState([]);
const [ouvertId, setOuvertId] = useState(''); const [ouvertId, setOuvertId] = useState('');
const [editionId, setEditionId] = useState('');
const [form, setForm] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@ -33,15 +35,86 @@ export default function GestionGroupes() {
useEffect(() => { charger(); }, []); useEffect(() => { charger(); }, []);
const installerDemo = async () => { const resetDemo = async () => {
const confirmation = window.prompt('Cette action ajoute le groupe de démonstration "Groupe Résilience" sans effacer les groupes existants. Tapez CONFIRMER pour continuer.'); const confirmation = window.prompt('Cette action réinitialise seulement les données fictives du groupe de démonstration. Tapez CONFIRMER pour continuer.');
if (confirmation !== 'CONFIRMER') return; if (confirmation !== 'CONFIRMER') return;
setBusy(true); setBusy(true);
setError(''); setError('');
setMsg(''); setMsg('');
try { try {
const res = await api.post('/admin/demo'); const res = await api.post('/admin/demo/reset');
setMsg(res.message || 'Groupe de démonstration installé'); setMsg(res.message || 'Groupe de démonstration réinitialisé');
await charger();
await loadGroupes();
} catch (e) {
setError(e.message);
} finally {
setBusy(false);
}
};
const nouveauGroupe = () => {
setEditionId('nouveau');
setForm({
nom: '',
district: '87-16',
region: '87',
adresse_local: '',
jour_reunion: 'mardi',
heure_reunion: '19:30',
assemblee_affaires_ordre: 'premiere',
format_reunion: 'Discussion ouverte',
lien_aa87: 'https://aa87.org',
});
};
const modifier = (groupe) => {
setEditionId(groupe.id);
setForm({
nom: groupe.nom || '',
district: groupe.district || '87-16',
region: groupe.region || '87',
adresse_local: groupe.adresse_local || '',
jour_reunion: groupe.jour_reunion || 'mardi',
heure_reunion: groupe.heure_reunion || '19:30',
assemblee_affaires_ordre: groupe.assemblee_affaires_ordre || 'premiere',
format_reunion: groupe.format_reunion || '',
lien_aa87: groupe.lien_aa87 || 'https://aa87.org',
});
setOuvertId(groupe.id);
};
const sauverGroupe = async () => {
if (!form?.nom?.trim()) { setError('Nom requis'); return; }
setBusy(true);
setError('');
try {
if (editionId === 'nouveau') {
await api.post('/admin/groupes', form);
setMsg('Groupe créé');
} else {
await api.patch(`/admin/groupes/${editionId}`, form);
setMsg('Groupe modifié');
}
setEditionId('');
setForm(null);
await charger();
await loadGroupes();
} catch (e) {
setError(e.message);
} finally {
setBusy(false);
}
};
const supprimer = async (groupe) => {
const confirmation = window.prompt(`Supprimer le groupe "${groupe.nom}" et ses données? Tapez SUPPRIMER pour continuer.`);
if (confirmation !== 'SUPPRIMER') return;
setBusy(true);
setError('');
try {
await api.delete(`/admin/groupes/${groupe.id}`);
setMsg('Groupe supprimé');
await charger(); await charger();
await loadGroupes(); await loadGroupes();
} catch (e) { } catch (e) {
@ -76,11 +149,11 @@ export default function GestionGroupes() {
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<button <button
onClick={installerDemo} onClick={nouveauGroupe}
disabled={busy} disabled={busy}
className="w-full min-h-[48px] p-3 bg-aa-blue text-white rounded-xl text-sm font-bold disabled:opacity-40" className="w-full min-h-[48px] p-3 bg-aa-blue text-white rounded-xl text-sm font-bold disabled:opacity-40"
> >
Installer le groupe de démonstration Créer un groupe
</button> </button>
<button <button
onClick={remettreAZero} onClick={remettreAZero}
@ -91,6 +164,34 @@ export default function GestionGroupes() {
</button> </button>
</div> </div>
{form && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<h2 className="font-bold text-gray-800">{editionId === 'nouveau' ? 'Nouveau groupe' : 'Modifier le groupe'}</h2>
<input value={form.nom} onChange={(e) => setForm({ ...form, nom: e.target.value })} placeholder="Nom du groupe" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<input value={form.adresse_local} onChange={(e) => setForm({ ...form, adresse_local: e.target.value })} placeholder="Adresse / coordonnées" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<div className="grid grid-cols-2 gap-2">
<input value={form.jour_reunion} onChange={(e) => setForm({ ...form, jour_reunion: e.target.value })} placeholder="Jour" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<input type="time" value={form.heure_reunion} onChange={(e) => setForm({ ...form, heure_reunion: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0" />
</div>
<div className="grid grid-cols-2 gap-2">
<input value={form.district} onChange={(e) => setForm({ ...form, district: e.target.value })} placeholder="District" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<input value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} placeholder="Région" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
</div>
<select value={form.assemblee_affaires_ordre} onChange={(e) => setForm({ ...form, assemblee_affaires_ordre: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0">
<option value="premiere">Assemblée la première réunion du mois</option>
<option value="deuxieme">Assemblée la deuxième réunion du mois</option>
<option value="troisieme">Assemblée la troisième réunion du mois</option>
<option value="quatrieme">Assemblée la quatrième réunion du mois</option>
<option value="derniere">Assemblée la dernière réunion du mois</option>
</select>
<textarea value={form.format_reunion} onChange={(e) => setForm({ ...form, format_reunion: e.target.value })} placeholder="Format de réunion" rows={2} className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<div className="grid grid-cols-2 gap-2">
<button onClick={sauverGroupe} disabled={busy} className="p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Enregistrer</button>
<button onClick={() => { setEditionId(''); setForm(null); }} className="p-3 bg-gray-100 text-gray-600 rounded-xl text-sm font-bold">Annuler</button>
</div>
</div>
)}
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>} {loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
<div className="space-y-3"> <div className="space-y-3">
@ -105,10 +206,18 @@ export default function GestionGroupes() {
className="flex w-full items-center justify-between gap-3 p-4 text-left active:bg-gray-50" className="flex w-full items-center justify-between gap-3 p-4 text-left active:bg-gray-50"
aria-expanded={ouvert} aria-expanded={ouvert}
> >
<span className="min-w-0"> <span className="min-w-0 flex-1">
<span className="block truncate text-lg font-bold text-gray-900">{groupe.nom}</span> <span className="block truncate text-lg font-bold text-gray-900">{groupe.nom}</span>
<span className="mt-1 block text-sm font-semibold text-aa-blue">{horaire}</span> <span className="mt-1 block text-sm font-semibold text-aa-blue">{horaire}</span>
</span> </span>
{groupe.demo && (
<span
onClick={(e) => { e.stopPropagation(); resetDemo(); }}
className="shrink-0 rounded-xl bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700"
>
Réinitialiser
</span>
)}
<span className={`shrink-0 text-2xl text-aa-blue transition ${ouvert ? 'rotate-180' : ''}`}>⌄</span> <span className={`shrink-0 text-2xl text-aa-blue transition ${ouvert ? 'rotate-180' : ''}`}>⌄</span>
</button> </button>
@ -125,6 +234,12 @@ export default function GestionGroupes() {
<p><span className="font-semibold text-gray-900">District : </span>{groupe.district || '—'}</p> <p><span className="font-semibold text-gray-900">District : </span>{groupe.district || '—'}</p>
<p><span className="font-semibold text-gray-900">Région : </span>{groupe.region || '—'}</p> <p><span className="font-semibold text-gray-900">Région : </span>{groupe.region || '—'}</p>
</div> </div>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => modifier(groupe)} className="p-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-bold">Modifier</button>
<button onClick={() => supprimer(groupe)} disabled={groupe.protege || busy} className="p-3 bg-red-50 text-red-700 rounded-xl text-sm font-bold disabled:opacity-40">
Supprimer
</button>
</div>
</div> </div>
)} )}
</section> </section>

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useStore } from '../stores/store'; import { useStore } from '../stores/store';
import { api } from '../stores/api';
const jourLabel = (jour) => { const jourLabel = (jour) => {
if (!jour) return 'Jour à préciser'; if (!jour) return 'Jour à préciser';
@ -7,7 +8,7 @@ const jourLabel = (jour) => {
}; };
export default function Login() { export default function Login() {
const { login, loading, error, groupes, loadGroupes, setPage } = useStore(); const { login, loadAccueil, loading, error, groupes, loadGroupes, setPage } = useStore();
const [courriel, setCourriel] = useState(localStorage.getItem('gm_courriel') || ''); const [courriel, setCourriel] = useState(localStorage.getItem('gm_courriel') || '');
const [pin, setPin] = useState(''); const [pin, setPin] = useState('');
const [groupeId, setGroupeId] = useState(localStorage.getItem('gm_groupe_id') || ''); const [groupeId, setGroupeId] = useState(localStorage.getItem('gm_groupe_id') || '');
@ -47,6 +48,25 @@ export default function Login() {
} catch {} } catch {}
}; };
const ouvrirSysadmin = async () => {
const pin = window.prompt('PIN Sysadmin');
if (!pin) return;
try {
const data = await api.post('/admin/sysadmin-login', { pin });
api.setToken(data.access_token, false);
useStore.setState({
token: data.access_token,
membre: data.membre,
groupeId: data.membre.groupe_id,
error: null,
});
await loadAccueil();
setPage('G14');
} catch (e) {
useStore.setState({ error: e.message });
}
};
return ( return (
<div className="min-h-screen bg-gradient-to-b from-aa-blue to-blue-900 p-4"> <div className="min-h-screen bg-gradient-to-b from-aa-blue to-blue-900 p-4">
<div className="mx-auto flex min-h-screen w-full max-w-md flex-col py-6"> <div className="mx-auto flex min-h-screen w-full max-w-md flex-col py-6">
@ -158,6 +178,13 @@ export default function Login() {
})} })}
</div> </div>
<button
type="button"
onClick={ouvrirSysadmin}
className="mx-auto mb-3 block px-3 py-2 text-xs font-semibold text-blue-200/80"
>
Sysadmin
</button>
<p className="text-center text-xs text-blue-300">100% logiciel libre · Hébergé au Québec</p> <p className="text-center text-xs text-blue-300">100% logiciel libre · Hébergé au Québec</p>
</div> </div>
</div> </div>