From 8fafb8e00086065d5dc23a4a86b776b98b6d346b Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 1 Jun 2026 06:45:47 -0400 Subject: [PATCH] Separer le mode sysadmin instance --- backend/app/core/security.py | 6 ++++-- backend/app/routers/admin.py | 19 +++++++++++++++---- backend/tests/test_business_rules.py | 9 +++++++++ frontend/src/App.jsx | 9 +++++++-- frontend/src/pages/Login.jsx | 15 ++++++++++++--- frontend/src/stores/store.js | 10 ++++++++++ 6 files changed, 57 insertions(+), 11 deletions(-) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 2c6693a..15a5eec 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -21,10 +21,12 @@ def verify_pin(pin: str, hashed: str) -> bool: return bcrypt.checkpw(pin.encode("utf-8"), hashed.encode("utf-8")) -def create_token(membre_id: str, groupe_id: str, expire_jours: int = None, admin_id: str | None = None) -> str: +def create_token(membre_id: str, groupe_id: str | None, expire_jours: int = None, admin_id: str | None = None) -> str: jours = expire_jours or settings.JWT_EXPIRE_DAYS expire = datetime.now(timezone.utc) + timedelta(days=jours) - payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire} + payload = {"sub": str(membre_id), "exp": expire} + if groupe_id: + payload["grp"] = str(groupe_id) if admin_id: payload["adm"] = str(admin_id) return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 067bacd..6a8f6e1 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -18,7 +18,6 @@ from app.models.groupe import Groupe from app.models.membre import Membre from app.models.reunion import Reunion from app.models.historique_modification import HistoriqueModification -from app.schemas.schemas import MembrePublic, TokenResponse router = APIRouter(prefix="/admin", tags=["Administration"]) @@ -44,13 +43,25 @@ async def require_sysadmin(membre=Depends(get_current_membre)): return membre -@router.post("/sysadmin-login", response_model=TokenResponse) +@router.post("/sysadmin-login") 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)) + token = create_token(str(membre.id), None, expire_jours=1) + return { + "access_token": token, + "token_type": "bearer", + "sysadmin_instance": True, + "membre": { + "id": str(membre.id), + "prenom": "Sysadmin", + "courriel": "sysadmin", + "groupe_id": None, + "date_abstinence": None, + "is_sysadmin": True, + }, + } raise HTTPException(status_code=401, detail="PIN Sysadmin invalide") diff --git a/backend/tests/test_business_rules.py b/backend/tests/test_business_rules.py index ae09d2c..873dbd9 100644 --- a/backend/tests/test_business_rules.py +++ b/backend/tests/test_business_rules.py @@ -8,6 +8,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.core.decision_types import type_decision_depuis_proposition from app.core.financial_position import PositionTresorerie from app.core.sanitization import safe_query_params +from app.core.security import create_token, decode_token from app.models.poste import Poste from app.models.affectation import Affectation from app.models.candidature import Candidature @@ -60,6 +61,14 @@ class AuditTests(unittest.TestCase): self.assertEqual(safe_query_params(request.query_params), {"pin": "***", "membre_id": "abc"}) +class SecurityTokenTests(unittest.TestCase): + def test_sysadmin_instance_token_has_no_group_context(self): + token = create_token("member-id", None, expire_jours=1) + payload = decode_token(token) + self.assertEqual(payload["sub"], "member-id") + self.assertNotIn("grp", payload) + + class MultiTenantTests(unittest.TestCase): def test_postes_are_group_scoped(self): self.assertIn("groupe_id", Poste.__table__.columns) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0982148..40bc945 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -36,14 +36,16 @@ import ModePapier from './pages/ModePapier'; import BottomNav from './components/BottomNav'; export default function App() { - const { token, page, navContext, loadAccueil, loadGroupes } = useStore(); + const { token, page, navContext, sysadminInstance, loadAccueil, loadGroupes } = useStore(); const chemin = window.location.pathname; useEffect(() => { const handleUnauthorized = () => useStore.getState().logout(); window.addEventListener('gm:unauthorized', handleUnauthorized); loadGroupes(); - if (token) { + if (token && sysadminInstance) { + useStore.setState({ page: 'G14' }); + } else if (token) { loadAccueil().then(() => { // Restaurer la page sauvegardée ou aller à l'accueil const savedPage = localStorage.getItem('gm_page'); @@ -62,6 +64,9 @@ export default function App() { // Pages protégées if (token) { + if (sysadminInstance) { + return
; + } let pageActive; switch (page) { case 'accueil': diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index c6b44b1..1938ae1 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -8,7 +8,7 @@ const jourLabel = (jour) => { }; export default function Login() { - const { login, loadAccueil, loading, error, groupes, loadGroupes, setPage } = useStore(); + const { login, loading, error, groupes, loadGroupes, setPage } = useStore(); const [courriel, setCourriel] = useState(localStorage.getItem('gm_courriel') || ''); const [pin, setPin] = useState(''); const [groupeId, setGroupeId] = useState(localStorage.getItem('gm_groupe_id') || ''); @@ -54,13 +54,22 @@ export default function Login() { try { const data = await api.post('/admin/sysadmin-login', { pin }); api.setToken(data.access_token, false); + localStorage.setItem('gm_sysadmin_instance', '1'); + localStorage.removeItem('gm_groupe_id'); useStore.setState({ token: data.access_token, membre: data.membre, - groupeId: data.membre.groupe_id, + groupeId: null, + groupe: null, + postes: ['Sysadmin'], + estExecutif: true, + estSysadmin: true, + modulesConsultation: [], + modulesSaisie: [], + modulesGestion: [], + sysadminInstance: true, error: null, }); - await loadAccueil(); setPage('G14'); } catch (e) { useStore.setState({ error: e.message }); diff --git a/frontend/src/stores/store.js b/frontend/src/stores/store.js index 262d2fa..2097950 100644 --- a/frontend/src/stores/store.js +++ b/frontend/src/stores/store.js @@ -6,6 +6,7 @@ export const useStore = create((set, get) => ({ token: api.getToken(), membre: null, groupeId: localStorage.getItem('gm_groupe_id'), + sysadminInstance: localStorage.getItem('gm_sysadmin_instance') === '1', groupes: [], // Accueil @@ -49,6 +50,7 @@ export const useStore = create((set, get) => ({ courriel, pin, groupe_id: groupeId, se_souvenir: seSouvenir, }); api.setToken(data.access_token, seSouvenir); + localStorage.removeItem('gm_sysadmin_instance'); localStorage.setItem('gm_groupe_id', groupeId); if (seSouvenir) { localStorage.setItem('gm_courriel', courriel); @@ -59,6 +61,7 @@ export const useStore = create((set, get) => ({ token: data.access_token, membre: data.membre, groupeId, + sysadminInstance: false, loading: false, }); await get().loadAccueil(); @@ -79,10 +82,12 @@ export const useStore = create((set, get) => ({ }; const res = await api.post('/auth/inscription', payload); api.setToken(res.access_token, false); + localStorage.removeItem('gm_sysadmin_instance'); localStorage.setItem('gm_groupe_id', data.groupe_id || ''); set({ token: res.access_token, membre: res.membre, + sysadminInstance: false, loading: false, }); await get().loadAccueil(); @@ -98,9 +103,12 @@ export const useStore = create((set, get) => ({ try { const data = await api.post(`/auth/impersoner/${membreId}`); api.setToken(data.access_token, false); + localStorage.removeItem('gm_sysadmin_instance'); set({ token: data.access_token, membre: data.membre, + groupeId: data.membre.groupe_id, + sysadminInstance: false, loading: false, }); await get().loadAccueil(); @@ -132,6 +140,7 @@ export const useStore = create((set, get) => ({ logout: () => { api.setToken(null); localStorage.removeItem('gm_groupe_id'); + localStorage.removeItem('gm_sysadmin_instance'); localStorage.removeItem('gm_page'); localStorage.removeItem('gm_nav_context'); set({ @@ -139,6 +148,7 @@ export const useStore = create((set, get) => ({ membre: null, groupe: null, groupeId: null, + sysadminInstance: false, postes: [], estExecutif: false, estSysadmin: false,