Aller au contenu

Authentification Collectivité (EPCI)

⬇️ Télécharger cette page en Markdown


Vue d'ensemble

L'authentification collectivité permet aux utilisateurs de se connecter en tant qu'EPCI (Établissement Public de Coopération Intercommunale) pour visualiser et gérer les incidents de toutes les communes membres simultanément.

Version

Fonctionnalité ajoutée dans v2.1 (Janvier 2026)


Architecture

Modèle de données

Table users:

mairie_id        INT          -- Mairie d'origine de l'utilisateur
collectivite_id  INT NULL     -- ID de la collectivité si utilisateur EPCI

Relation: - Utilisateur mairie: collectivite_id = NULL → accès uniquement à sa mairie - Utilisateur collectivité: collectivite_id = 18 → accès aux 15 communes de la CA Riviera Française

Table services_mairie:

scope            ENUM('mairie', 'collectivite')  -- Niveau du service
mairie_id        INT NULL                        -- Si scope='mairie'
collectivite_id  INT NULL                        -- Si scope='collectivite'

Table mairies:

latitude   DECIMAL(10,8) NULL  -- Coordonnées GPS pour affichage carte
longitude  DECIMAL(11,8) NULL


Fonctionnalités

1. Dashboard multi-villes

Carte interactive Leaflet:

// Marqueurs violets 🏛️ pour chaque commune
communes.forEach(function(commune) {
    const icon = L.divIcon({
        html: `<div style="background: #8B5CF6; color: white; ...">
            🏛️ ${commune.ville} (${commune.nb_incidents})
        </div>`
    });

    L.marker([lat, lon], {icon: icon})
     .bindPopup(`<strong>${commune.ville}</strong><br>
                 Nouveaux: ${commune.nb_nouveaux}<br>
                 En cours: ${commune.nb_en_cours}<br>
                 Résolus: ${commune.nb_resolus}`)
     .addTo(communeMarkers);

    bounds.extend([lat, lon]);
});

// Zoom automatique sur toutes les communes
if (bounds.isValid()) {
    map.fitBounds(bounds, {padding: [50, 50]});
}

Carte "Répartition par commune":

<?php if ($auth->isCollectiviteUser()): ?>
<div class="card">
    <div class="card-header"><h3>Répartition par commune</h3></div>
    <?php
    $cityStats = $db->fetchAll(
        "SELECT m.ville, COUNT(i.id) as total,
         SUM(CASE WHEN i.statut = 'nouveau' THEN 1 ELSE 0 END) as nouveaux
         FROM mairies m
         LEFT JOIN incidents i ON m.id = i.mairie_id
         WHERE m.id IN ($mairiePlaceholders)
         GROUP BY m.id ORDER BY total DESC",
        $mairieIds
    );
    ?>
    <table>...</table>
</div>
<?php endif; ?>

2. Liste incidents multi-villes

Dropdown filtre commune:

<?php if ($auth->isCollectiviteUser()): ?>
<div class="form-group">
    <label for="mairie">Commune</label>
    <select id="mairie" name="mairie">
        <option value="">Toutes les communes</option>
        <?php foreach ($auth->getAccessibleMairies() as $mairie): ?>
        <option value="<?= $mairie['id'] ?>">
            <?= htmlspecialchars($mairie['ville']) ?>
        </option>
        <?php endforeach; ?>
    </select>
</div>
<?php endif; ?>

Badge ville sur chaque incident:

<?php if ($auth->isCollectiviteUser()): ?>
    <span style="font-size: 11px; color: #6b7280; background: #f3f4f6;">
        <?= htmlspecialchars($incident['mairie_ville']) ?>
    </span>
<?php endif; ?>

3. Services intercommunaux

Formulaire création service:

<div class="form-group">
    <label for="scope">Niveau</label>
    <select id="scope" name="scope" required>
        <option value="mairie">Service de la mairie</option>
        <?php if ($auth->isCollectiviteUser()): ?>
        <option value="collectivite">Service intercommunal (EPCI)</option>
        <?php endif; ?>
    </select>
</div>

Backend INSERT:

$scope = $_POST['scope'] ?? 'mairie';
$collectiviteId = null;

if ($scope === 'collectivite' && $auth->isCollectiviteUser()) {
    $collectiviteId = $auth->getCollectiviteId();
    $mairieId = null;
}

$db->execute(
    "INSERT INTO services_mairie (mairie_id, collectivite_id, scope, nom, couleur)
     VALUES (?, ?, ?, ?, ?)",
    [$mairieId, $collectiviteId, $scope, $nom, $couleur]
);

Méthodes Auth

Nouvelles méthodes (v2.1)

// Obtenir l'ID de la collectivité
public function getCollectiviteId(): ?int {
    return $_SESSION['collectivite_id'] ?? null;
}

// Vérifier si utilisateur collectivité
public function isCollectiviteUser(): bool {
    return !empty($_SESSION['collectivite_id']);
}

// Obtenir tous les IDs de mairies accessibles
public function getMairieIdsForUser(): array {
    $collectiviteId = $this->getCollectiviteId();

    if (!$collectiviteId) {
        return [$this->getMairieId()];
    }

    $mairies = $this->db->fetchAll(
        "SELECT m.id FROM mairies m
         JOIN collectivites c ON m.code_siren_epci = c.code_siren_epci
         WHERE c.id = ?",
        [$collectiviteId]
    );

    return array_column($mairies, 'id');
}

// Obtenir les objets mairie complets (pour dropdowns)
public function getAccessibleMairies(): array {
    $collectiviteId = $this->getCollectiviteId();

    if (!$collectiviteId) {
        $mairie = $this->db->fetchOne(
            "SELECT id, nom, ville FROM mairies WHERE id = ?",
            [$this->getMairieId()]
        );
        return $mairie ? [$mairie] : [];
    }

    return $this->db->fetchAll(
        "SELECT m.id, m.nom, m.ville
         FROM mairies m
         JOIN collectivites c ON m.code_siren_epci = c.code_siren_epci
         WHERE c.id = ?
         ORDER BY m.ville ASC",
        [$collectiviteId]
    );
}

// Vérifier accès à un incident spécifique
public function canAccessIncident(int $incidentId): bool {
    $accessibleMairieIds = $this->getMairieIdsForUser();
    $accessibleTypeIds = $this->getAccessibleTypeIds();

    $placeholders = implode(',', array_fill(0, count($accessibleMairieIds), '?'));
    $sql = "SELECT COUNT(*) as count FROM incidents
            WHERE id = ? AND mairie_id IN ($placeholders)";
    $params = array_merge([$incidentId], $accessibleMairieIds);

    if ($accessibleTypeIds !== null && !empty($accessibleTypeIds)) {
        $typePlaceholders = implode(',', array_fill(0, count($accessibleTypeIds), '?'));
        $sql .= " AND type_id IN ($typePlaceholders)";
        $params = array_merge($params, $accessibleTypeIds);
    }

    $result = $this->db->fetchOne($sql, $params);
    return ($result['count'] ?? 0) > 0;
}

Pattern d'implémentation

Requête standard (avant)

$incidents = $db->fetchAll(
    "SELECT * FROM incidents WHERE mairie_id = ?",
    [$auth->getMairieId()]
);

Requête multi-villes (après)

$mairieIds = $auth->getMairieIdsForUser();
$placeholders = implode(',', array_fill(0, count($mairieIds), '?'));

$incidents = $db->fetchAll(
    "SELECT * FROM incidents WHERE mairie_id IN ($placeholders)",
    $mairieIds
);

Sécurité

Toujours utiliser prepared statements avec array_fill() pour les clauses IN. Ne jamais concaténer les IDs directement dans la requête SQL.


Migration base de données

Fichier: database/migration_add_collectivite_auth.sql

-- Ajouter collectivite_id aux utilisateurs
ALTER TABLE users
  ADD COLUMN collectivite_id INT NULL AFTER mairie_id,
  ADD KEY idx_collectivite_id (collectivite_id),
  ADD CONSTRAINT fk_users_collectivite
    FOREIGN KEY (collectivite_id) REFERENCES collectivites(id) ON DELETE SET NULL;

-- Ajouter scope et collectivite_id aux services
ALTER TABLE services_mairie
  ADD COLUMN scope ENUM('mairie', 'collectivite') NOT NULL DEFAULT 'mairie',
  ADD COLUMN collectivite_id INT NULL AFTER scope,
  ADD KEY idx_services_collectivite (collectivite_id),
  ADD CONSTRAINT fk_services_collectivite
    FOREIGN KEY (collectivite_id) REFERENCES collectivites(id) ON DELETE CASCADE;

-- Ajouter coordonnées GPS aux mairies
ALTER TABLE mairies
  ADD COLUMN latitude DECIMAL(10, 8) NULL AFTER ville,
  ADD COLUMN longitude DECIMAL(11, 8) NULL AFTER latitude,
  ADD INDEX idx_gps (latitude, longitude);

-- Index pour performance requêtes multi-mairies
CREATE INDEX idx_incidents_mairie_type_statut
  ON incidents (mairie_id, type_id, statut);

Commande d'exécution:

docker-compose exec -T db mysql -u root -purbafix_root_2024 urbafix \
  < database/migration_add_collectivite_auth.sql

Géocodage des communes

Script général: populate_mairies_gps.php

Étape 1: Calcul coordonnées à partir des incidents existants (AVG) Étape 2: Géocodage via API Nominatim pour communes sans coordonnées

docker-compose exec web php /var/www/html/database/populate_mairies_gps.php

Sortie:

Étape 1: Calcul à partir des incidents...
Mairies avec GPS (depuis incidents): 142

Étape 2: Géocodage des mairies sans GPS...
Mairies à géocoder: 100
  ✓ Antibes (06600): 43.58084150, 7.12354470
  ✓ Cannes (06400): 43.55183920, 7.01270630
  ...
Total mairies avec GPS: 242

Script spécifique: geocode_riviera.php

Géocode uniquement les communes d'une collectivité spécifique (ID 18 = CA Riviera Française):

docker-compose exec web php /var/www/html/database/geocode_riviera.php

Sortie:

=== GÉOCODAGE COMMUNES CA RIVIERA FRANÇAISE ===

Communes à géocoder: 12

Géocodage de Beausoleil (06240)...
  ✓ GPS trouvé: 43.74828100, 7.42196770
Géocodage de Breil-sur-Roya (06540)...
  ✓ GPS trouvé: 43.93753210, 7.51396640
...

=== RÉSULTAT ===
Géocodées: 12
Échecs: 0

=== VÉRIFICATION FINALE ===
  ✓ Beausoleil: 43.74828100, 7.42196770
  ✓ Breil-sur-Roya: 43.93753210, 7.51396640
  ...
Total: 15 / 15 communes avec GPS


Création utilisateur collectivité

Via SQL

INSERT INTO users (
    mairie_id,
    collectivite_id,
    nom,
    prenom,
    email,
    password,
    role,
    actif,
    voir_tous_incidents
) VALUES (
    36825,  -- Mairie d'origine (Menton)
    18,     -- CA Riviera Française
    'Test',
    'Riviera',
    'test.riviera@example.com',
    '<hash bcrypt généré avec password_hash()>',  -- ne jamais publier un vrai hash
    'admin',
    1,
    1
);

Via interface admin (users.php)

Formulaire:

  1. Nom, prénom, email (champs standards)
  2. Nouveau: Dropdown "Collectivité (EPCI)"
  3. Sélection de la collectivité associée à la mairie

Backend INSERT:

$collectiviteId = !empty($_POST['collectivite_id']) ? intval($_POST['collectivite_id']) : null;

$db->execute(
    "INSERT INTO users (mairie_id, collectivite_id, password, nom, prenom, email, role)
     VALUES (?, ?, ?, ?, ?, ?, ?)",
    [$mairieId, $collectiviteId, $hashedPassword, $nom, $prenom, $email, $role]
);

Exemple production: CA Riviera Française

Collectivité: - ID: 18 - Code SIREN EPCI: 200030195 - Nom: CA de la Riviera Française - Nombre de communes: 15

Communes membres:

Commune Code Postal Latitude Longitude
Beausoleil 06240 43.74828100 7.42196770
Breil-sur-Roya 06540 43.93753210 7.51396640
Castellar 06500 43.80800640 7.50035550
Castillon 06500 43.83555190 7.44851650
Fontan 06540 43.98216720 7.55064280
Gorbio 06500 43.78612740 7.44386630
La Brigue 06430 44.06651550 7.61732600
La Turbie 06320 43.74553420 7.40076800
Menton 06500 43.78151729 7.49551457
Moulinet 06380 43.92873170 7.40829580
Roquebrune-Cap-Martin 06190 43.76366790 7.47553060
Sainte-Agnès 06500 43.79777890 7.45939590
Saorge 06540 43.98389870 7.55160100
Sospel 06380 43.87848700 7.44867090
Tende 06430 44.08906690 7.59370710

Utilisateur test: - Email: test.riviera@example.com - Mot de passe: défini à la création du compte (non documenté) - Accès: Toutes les 15 communes ci-dessus - URL: https://urbafix.fr/login.php


Tests à effectuer

Test 1: Création utilisateur collectivité

UPDATE users
SET collectivite_id = 18
WHERE email = 'test@example.com';

Test 2: Vérifier accès multi-villes

  1. Login utilisateur collectivité
  2. Dashboard:
  3. ✅ Stats agrégées (toutes communes)
  4. ✅ Carte avec 15 marqueurs violets 🏛️
  5. ✅ Zoom englobant toutes les communes
  6. ✅ Carte "Répartition par commune"
  7. Incidents:
  8. ✅ Liste affiche incidents de toutes les villes
  9. ✅ Dropdown filtre commune disponible
  10. ✅ Badge ville affiché sur chaque incident
  11. Détail incident:
  12. ✅ Accès incidents de toutes les villes membres
  13. ✅ Badge ville affiché dans header

Test 3: Sécurité

// Tenter d'accéder à incident hors collectivité
$canAccess = $auth->canAccessIncident(99999);
// Doit retourner false si incident n'appartient pas à la collectivité
  • ✅ Queries utilisent prepared statements
  • ✅ Filtrage appliqué au niveau SQL (pas juste UI)
  • ✅ Pas de fuite de données entre collectivités

Test 4: Services intercommunaux

  1. Créer service scope='collectivite'
  2. Assigner types d'incidents au service
  3. Assigner utilisateur d'une autre ville membre
  4. Vérifier filtrage types fonctionne à travers toutes les villes

Rollback

Si besoin de désactiver la fonctionnalité:

-- Retirer collectivite_id de tous les utilisateurs
UPDATE users SET collectivite_id = NULL;

-- Supprimer les contraintes et colonnes
ALTER TABLE users DROP FOREIGN KEY fk_users_collectivite;
ALTER TABLE users DROP COLUMN collectivite_id;

ALTER TABLE services_mairie DROP FOREIGN KEY fk_services_collectivite;
ALTER TABLE services_mairie DROP COLUMN collectivite_id;
ALTER TABLE services_mairie DROP COLUMN scope;

-- Retirer GPS des mairies (optionnel)
ALTER TABLE mairies DROP COLUMN latitude;
ALTER TABLE mairies DROP COLUMN longitude;

Documentation complémentaire

  • Guide complet: /srv/urbafix/AUTHENTIFICATION_COLLECTIVITES.md
  • Import collectivités: /srv/urbafix/database/README_COLLECTIVITES.md
  • Guide admin: /srv/urbafix/GUIDE_ADMIN.md (section Collectivité)

Navigation

Retour à Interface backoffice | Permissions