feat: Livraison version 3.0.6

- Amélioration de la gestion des entités et des utilisateurs
- Mise à jour des modèles Amicale et Client avec champs supplémentaires
- Ajout du service de logging et amélioration du chargement UI
- Refactoring des formulaires utilisateur et amicale
- Intégration de file_picker et image_picker pour la gestion des fichiers
- Amélioration de la gestion des membres et de leur suppression
- Optimisation des performances de l'API
- Mise à jour de la documentation technique

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-08 20:33:54 +02:00
parent 599b9fcda0
commit 206c76c7db
69 changed files with 203569 additions and 174972 deletions

View File

@@ -9,6 +9,7 @@
5. [Base de données](#base-de-données)
6. [Sécurité](#sécurité)
7. [Endpoints API](#endpoints-api)
8. [Changements récents](#changements-récents)
## Structure du projet
@@ -126,6 +127,54 @@ Exemple détaillé du parcours d'une requête POST /api/users :
- Gère le pool de connexions
- Assure la sécurité des requêtes
## Base de données
### Structure des tables principales
#### Table `users`
- `encrypted_user_name` : Identifiant de connexion chiffré (unique)
- `encrypted_email` : Email chiffré (unique)
- `user_pass_hash` : Hash du mot de passe
- `encrypted_name`, `encrypted_phone`, `encrypted_mobile` : Données personnelles chiffrées
- Autres champs : `first_name`, `sect_name`, `fk_role`, `fk_entite`, etc.
#### Table `entites` (Amicales)
- `chk_mdp_manuel` (DEFAULT 0) : Gestion manuelle des mots de passe
- `chk_username_manuel` (DEFAULT 0) : Gestion manuelle des identifiants
- `chk_stripe` : Activation des paiements Stripe
- Données chiffrées : `encrypted_name`, `encrypted_email`, `encrypted_phone`, etc.
#### Table `medias`
- `support` : Type de support (entite, user, operation, passage)
- `support_id` : ID de l'élément associé
- `file_category` : Catégorie (logo, export, carte, etc.)
- `file_path` : Chemin complet du fichier
- `processed_width/height` : Dimensions après traitement
- Utilisée pour stocker les logos des entités
### Chiffrement des données
Toutes les données sensibles sont chiffrées avec AES-256-CBC :
- Emails, noms, téléphones
- Identifiants de connexion
- Informations bancaires (IBAN, BIC)
### Migration de base de données
Script SQL pour ajouter les nouveaux champs :
```sql
-- Ajout de la gestion manuelle des usernames
ALTER TABLE `entites`
ADD COLUMN `chk_username_manuel` tinyint(1) unsigned NOT NULL DEFAULT 0
COMMENT 'Gestion des usernames manuelle (1) ou automatique (0)'
AFTER `chk_mdp_manuel`;
-- Index pour optimiser la vérification d'unicité
ALTER TABLE `users`
ADD INDEX `idx_encrypted_user_name` (`encrypted_user_name`);
```
## Sécurité
### Mesures implémentées
@@ -137,6 +186,8 @@ Exemple détaillé du parcours d'une requête POST /api/users :
- Gestion des CORS
- Session sécurisée
- Authentification requise
- Chiffrement AES-256 des données sensibles
- Envoi séparé des identifiants par email
## Endpoints API
@@ -223,39 +274,260 @@ La configuration des sessions inclut :
#### Création d'utilisateur
La création d'utilisateur s'adapte aux paramètres de l'entité (amicale) :
```http
POST /api/users
Content-Type: application/json
Authorization: Bearer {session_id}
{
"name": "John Doe",
"email": "john@example.com",
"password": "SecurePassword123"
"name": "John Doe",
"first_name": "John",
"role": 1,
"fk_entite": 5,
"username": "j.doe38", // Requis si chk_username_manuel=1 pour l'entité
"password": "SecurePass123", // Requis si chk_mdp_manuel=1 pour l'entité
"phone": "0476123456",
"mobile": "0612345678",
"sect_name": "Secteur A",
"date_naissance": "1990-01-15",
"date_embauche": "2020-03-01"
}
```
**Comportement selon les paramètres de l'entité :**
| chk_username_manuel | chk_mdp_manuel | Comportement |
|---------------------|----------------|--------------|
| 0 | 0 | Username et password générés automatiquement |
| 0 | 1 | Username généré, password requis dans le payload |
| 1 | 0 | Username requis dans le payload, password généré |
| 1 | 1 | Username et password requis dans le payload |
**Validation du username (si manuel) :**
- Format : 10-30 caractères
- Commence par une lettre
- Caractères autorisés : a-z, 0-9, ., -, _
- Doit être unique dans toute la base
**Réponse réussie :**
```json
{
"message": "Utilisateur créé",
"id": "123"
"status": "success",
"message": "Utilisateur créé avec succès",
"id": 123,
"username": "j.doe38", // Toujours retourné
"password": "xY7#mK9@pL2" // Retourné seulement si généré automatiquement
}
```
**Envoi d'emails :**
- **Email 1** : Identifiant de connexion (toujours envoyé)
- **Email 2** : Mot de passe (toujours envoyé, 1 seconde après le premier)
**Codes de statut :**
- 201: Création réussie
- 400: Données invalides
- 400: Données invalides ou username/password manquant si requis
- 401: Non authentifié
- 403: Accès non autorisé (rôle insuffisant)
- 409: Email ou username déjà utilisé
- 500: Erreur serveur
#### Vérification de disponibilité du username
```http
POST /api/users/check-username
Content-Type: application/json
Authorization: Bearer {session_id}
{
"username": "j.doe38"
}
```
**Réponse si disponible :**
```json
{
"status": "success",
"available": true,
"message": "Nom d'utilisateur disponible",
"username": "j.doe38"
}
```
**Réponse si déjà pris :**
```json
{
"status": "success",
"available": false,
"message": "Ce nom d'utilisateur est déjà utilisé",
"suggestions": ["j.doe38_42", "j.doe381234", "j.doe3825"]
}
```
#### Autres endpoints
- GET /api/users
- GET /api/users/{id}
- PUT /api/users/{id}
- DELETE /api/users/{id}
- POST /api/users/{id}/reset-password
### Entités (Amicales)
#### Upload du logo d'une entité
```http
POST /api/entites/{id}/logo
Content-Type: multipart/form-data
Authorization: Bearer {session_id}
Body:
logo: File (image/png, image/jpeg, image/jpg)
```
**Restrictions :**
- Réservé aux administrateurs d'amicale (fk_role == 2)
- L'admin ne peut uploader que le logo de sa propre amicale
- Un seul logo actif par entité (le nouveau remplace l'ancien)
**Traitement de l'image :**
- Formats acceptés : PNG, JPG, JPEG
- Redimensionnement automatique : 250x250px maximum (ratio conservé)
- Résolution : 72 DPI (standard web)
- Préservation de la transparence pour les PNG
**Stockage :**
- Chemin : `/uploads/entites/{id}/logo/logo_{id}_{timestamp}.{ext}`
- Enregistrement dans la table `medias`
- Suppression automatique de l'ancien logo
**Réponse réussie :**
```json
{
"status": "success",
"message": "Logo uploadé avec succès",
"media_id": 42,
"file_name": "logo_5_1234567890.jpg",
"file_path": "/entites/5/logo/logo_5_1234567890.jpg",
"dimensions": {
"width": 250,
"height": 180
}
}
```
#### Récupération du logo d'une entité
```http
GET /api/entites/{id}/logo
Authorization: Bearer {session_id}
```
**Réponse :**
```json
{
"status": "success",
"logo": {
"id": 42,
"data_url": "data:image/png;base64,iVBORw0KGgoAAAANS...",
"file_name": "logo_5_1234567890.png",
"mime_type": "image/png",
"width": 250,
"height": 180,
"size": 15234
}
}
```
**Note :** Le logo est également inclus automatiquement dans la réponse du login si disponible.
#### Mise à jour d'une entité
```http
PUT /api/entites/{id}
Content-Type: application/json
Authorization: Bearer {session_id}
{
"name": "Amicale de Grenoble",
"adresse1": "123 rue de la Caserne",
"adresse2": "",
"code_postal": "38000",
"ville": "Grenoble",
"phone": "0476123456",
"mobile": "0612345678",
"email": "contact@amicale38.fr",
"chk_stripe": true, // Activation paiement Stripe
"chk_mdp_manuel": false, // Génération auto des mots de passe
"chk_username_manuel": false, // Génération auto des usernames
"chk_copie_mail_recu": true,
"chk_accept_sms": false
}
```
**Paramètres de gestion des membres :**
| Paramètre | Type | Description |
|-----------|------|-------------|
| chk_mdp_manuel | boolean | `true`: L'admin saisit les mots de passe<br>`false`: Génération automatique |
| chk_username_manuel | boolean | `true`: L'admin saisit les identifiants<br>`false`: Génération automatique |
| chk_stripe | boolean | Active/désactive les paiements Stripe |
**Note :** Ces paramètres sont modifiables uniquement par les administrateurs (fk_role > 1).
#### Réponse du login avec paramètres entité
Lors du login, les paramètres de l'entité sont retournés dans le groupe `amicale` :
```json
{
"status": "success",
"session_id": "abc123...",
"session_expiry": "2025-01-09T15:30:00+00:00",
"user": {
"id": 9999980,
"fk_entite": 5,
"fk_role": 2,
"fk_titre": null,
"first_name": "Pierre",
"sect_name": "",
"date_naissance": "1990-01-15", // Maintenant correctement récupéré
"date_embauche": "2020-03-01", // Maintenant correctement récupéré
"username": "pv_admin",
"name": "VALERY ADM",
"phone": "0476123456", // Maintenant correctement récupéré
"mobile": "0612345678", // Maintenant correctement récupéré
"email": "contact@resalice.com"
},
"amicale": {
"id": 5,
"name": "Amicale de Grenoble",
"chk_mdp_manuel": 0,
"chk_username_manuel": 0,
"chk_stripe": 1,
"logo": { // Logo de l'entité (si disponible)
"id": 42,
"data_url": "data:image/png;base64,iVBORw0KGgoAAAANS...",
"file_name": "logo_5_1234567890.png",
"mime_type": "image/png",
"width": 250,
"height": 180
}
// ... autres champs
}
}
```
Ces paramètres permettent à l'application Flutter d'adapter dynamiquement le formulaire de création de membre.
## Intégration Frontend
@@ -298,3 +570,45 @@ fetch('/api/endpoint', {
- Surveillance de la base de données
- Monitoring des performances
- Alertes sur erreurs critiques
## Changements récents
### Version 3.0.6 (Janvier 2025)
#### 1. Correction des rôles administrateurs
- **Avant :** Les administrateurs d'amicale devaient avoir `fk_role > 2`
- **Après :** Les administrateurs d'amicale ont `fk_role > 1` (donc rôle 2 et plus)
- **Impact :** Les champs `chk_stripe`, `chk_mdp_manuel`, `chk_username_manuel` sont maintenant modifiables par les admins d'amicale (rôle 2)
#### 2. Envoi systématique des deux emails lors de la création d'utilisateur
- **Avant :** Le 2ème email (mot de passe) n'était envoyé que si le mot de passe était généré automatiquement
- **Après :** Les deux emails sont toujours envoyés lors de la création d'un membre
- Email 1 : Identifiant (username)
- Email 2 : Mot de passe (1 seconde après)
- **Raison :** Le nouveau membre a toujours besoin des deux informations pour se connecter
#### 3. Ajout des champs manquants dans la réponse du login
- **Champs ajoutés dans la requête SQL :**
- `fk_titre`
- `date_naissance`
- `date_embauche`
- `encrypted_phone`
- `encrypted_mobile`
- **Impact :** Ces données sont maintenant correctement retournées dans l'objet `user` lors du login
#### 4. Système de gestion des logos d'entité
- **Nouvelle fonctionnalité :** Upload et gestion des logos pour les amicales
- **Routes ajoutées :**
- `POST /api/entites/{id}/logo` : Upload d'un nouveau logo
- `GET /api/entites/{id}/logo` : Récupération du logo
- **Caractéristiques :**
- Réservé aux administrateurs d'amicale (fk_role == 2)
- Un seul logo actif par entité
- Redimensionnement automatique (250x250px max)
- Format base64 dans les réponses JSON (compatible Flutter)
- Logo inclus automatiquement dans la réponse du login
#### 5. Amélioration de l'intégration Flutter
- **Format d'envoi des images :** Base64 data URL pour compatibilité multiplateforme
- **Structure de réponse enrichie :** Le logo est inclus dans l'objet `amicale` lors du login
- **Optimisation :** Pas de requête HTTP supplémentaire nécessaire pour afficher le logo

View File

@@ -220,7 +220,8 @@ CREATE TABLE `entites` (
`encrypted_iban` varchar(255) DEFAULT '',
`encrypted_bic` varchar(128) DEFAULT '',
`chk_demo` tinyint(1) unsigned DEFAULT 1,
`chk_mdp_manuel` tinyint(1) unsigned NOT NULL DEFAULT 1 COMMENT 'Gestion des mots de passe manuelle O/N',
`chk_mdp_manuel` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT 'Gestion des mots de passe manuelle (1) ou automatique (0)',
`chk_username_manuel` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT 'Gestion des usernames manuelle (1) ou automatique (0)',
`chk_copie_mail_recu` tinyint(1) unsigned NOT NULL DEFAULT 0,
`chk_accept_sms` tinyint(1) unsigned NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'Date de création',

View File

@@ -0,0 +1,25 @@
-- Script de migration pour ajouter le champ chk_username_manuel et modifier chk_mdp_manuel
-- Date: 2025-08-07
-- Description: Permet aux administrateurs d'entité de choisir entre saisie manuelle ou génération automatique des usernames et mots de passe
-- Modification du champ chk_mdp_manuel pour mettre la valeur par défaut à 0 (génération automatique)
ALTER TABLE `entites`
MODIFY COLUMN `chk_mdp_manuel` tinyint(1) unsigned NOT NULL DEFAULT 0
COMMENT 'Gestion des mots de passe manuelle (1) ou automatique (0)';
-- Ajout du champ chk_username_manuel dans la table entites
ALTER TABLE `entites`
ADD COLUMN `chk_username_manuel` tinyint(1) unsigned NOT NULL DEFAULT 0
COMMENT 'Gestion des usernames manuelle (1) ou automatique (0)'
AFTER `chk_mdp_manuel`;
-- Par défaut, on met à 0 (génération automatique) pour toutes les entités existantes
-- Cela permet de garder le comportement actuel par défaut
UPDATE `entites` SET `chk_username_manuel` = 0 WHERE `chk_username_manuel` IS NULL;
-- Ajout d'un index sur encrypted_user_name pour améliorer les performances de vérification d'unicité
ALTER TABLE `users`
ADD INDEX `idx_encrypted_user_name` (`encrypted_user_name`);
-- Message de confirmation
SELECT CONCAT('Migration effectuée : chk_username_manuel ajouté à la table entites') AS 'Status';

View File

@@ -415,7 +415,7 @@ class EntiteController {
}
$userRole = (int)$user['fk_role'];
$isAdmin = $userRole > 2;
$isAdmin = $userRole > 1;
// Récupérer les données de la requête
$data = Request::getJson();
@@ -577,6 +577,16 @@ class EntiteController {
$updateFields[] = 'chk_stripe = ?';
$params[] = $data['chk_stripe'] ? 1 : 0;
}
if (isset($data['chk_mdp_manuel'])) {
$updateFields[] = 'chk_mdp_manuel = ?';
$params[] = $data['chk_mdp_manuel'] ? 1 : 0;
}
if (isset($data['chk_username_manuel'])) {
$updateFields[] = 'chk_username_manuel = ?';
$params[] = $data['chk_username_manuel'] ? 1 : 0;
}
}
// Si aucun champ à mettre à jour, retourner une erreur
@@ -631,4 +641,341 @@ class EntiteController {
], 500);
}
}
/**
* Upload et traite le logo d'une entité
* Réservé aux administrateurs d'amicale (fk_role == 2)
*
* @param string $id ID de l'entité
* @return void
*/
public function uploadLogo(string $id): void {
try {
// Vérifier l'authentification
$userId = Session::getUserId();
if (!$userId) {
Response::json([
'status' => 'error',
'message' => 'Vous devez être connecté pour effectuer cette action'
], 401);
return;
}
// Récupérer les infos de l'utilisateur
$stmt = $this->db->prepare('SELECT fk_role, fk_entite FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
Response::json([
'status' => 'error',
'message' => 'Utilisateur non trouvé'
], 404);
return;
}
// Vérifier que l'utilisateur est admin d'amicale (fk_role == 2)
if ((int)$user['fk_role'] !== 2) {
Response::json([
'status' => 'error',
'message' => 'Seuls les administrateurs d\'amicale peuvent uploader un logo'
], 403);
return;
}
// Vérifier que l'entité correspond à celle de l'utilisateur
$entiteId = (int)$id;
if ($entiteId !== (int)$user['fk_entite']) {
Response::json([
'status' => 'error',
'message' => 'Vous ne pouvez modifier que le logo de votre propre amicale'
], 403);
return;
}
// Vérifier qu'un fichier a été envoyé
if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
Response::json([
'status' => 'error',
'message' => 'Aucun fichier reçu ou erreur lors de l\'upload'
], 400);
return;
}
$uploadedFile = $_FILES['logo'];
// Vérifier le type MIME
$allowedMimes = ['image/jpeg', 'image/jpg', 'image/png'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $uploadedFile['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedMimes)) {
Response::json([
'status' => 'error',
'message' => 'Format de fichier non autorisé. Seuls PNG, JPG et JPEG sont acceptés'
], 400);
return;
}
// Déterminer l'extension
$extension = match($mimeType) {
'image/jpeg', 'image/jpg' => 'jpg',
'image/png' => 'png',
default => 'jpg'
};
// Créer le dossier de destination
require_once __DIR__ . '/../Services/FileService.php';
$fileService = new \FileService();
$uploadPath = "/entites/{$entiteId}/logo";
$fullPath = $fileService->createDirectory($entiteId, $uploadPath);
// Nom du fichier final
$fileName = "logo_{$entiteId}_" . time() . ".{$extension}";
$filePath = $fullPath . '/' . $fileName;
// Charger l'image avec GD
$sourceImage = match($mimeType) {
'image/jpeg', 'image/jpg' => imagecreatefromjpeg($uploadedFile['tmp_name']),
'image/png' => imagecreatefrompng($uploadedFile['tmp_name']),
default => false
};
if (!$sourceImage) {
Response::json([
'status' => 'error',
'message' => 'Impossible de traiter l\'image'
], 500);
return;
}
// Obtenir les dimensions originales
$originalWidth = imagesx($sourceImage);
$originalHeight = imagesy($sourceImage);
// Calculer les nouvelles dimensions (max 250px) en gardant le ratio
$maxSize = 250;
$ratio = min($maxSize / $originalWidth, $maxSize / $originalHeight, 1);
$newWidth = (int)($originalWidth * $ratio);
$newHeight = (int)($originalHeight * $ratio);
// Créer l'image redimensionnée
$resizedImage = imagecreatetruecolor($newWidth, $newHeight);
// Préserver la transparence pour les PNG
if ($mimeType === 'image/png') {
imagealphablending($resizedImage, false);
imagesavealpha($resizedImage, true);
$transparent = imagecolorallocatealpha($resizedImage, 255, 255, 255, 127);
imagefilledrectangle($resizedImage, 0, 0, $newWidth, $newHeight, $transparent);
}
// Redimensionner
imagecopyresampled(
$resizedImage, $sourceImage,
0, 0, 0, 0,
$newWidth, $newHeight,
$originalWidth, $originalHeight
);
// Sauvegarder l'image (72 DPI est la résolution standard web)
$saved = match($mimeType) {
'image/jpeg', 'image/jpg' => imagejpeg($resizedImage, $filePath, 85),
'image/png' => imagepng($resizedImage, $filePath, 8),
default => false
};
// Libérer la mémoire
imagedestroy($sourceImage);
imagedestroy($resizedImage);
if (!$saved) {
Response::json([
'status' => 'error',
'message' => 'Impossible de sauvegarder l\'image'
], 500);
return;
}
// Appliquer les permissions
$fileService->setFilePermissions($filePath);
// Supprimer l'ancien logo s'il existe
$stmt = $this->db->prepare('
SELECT id, file_path FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
');
$stmt->execute(['entite', $entiteId, 'logo']);
$oldLogo = $stmt->fetch(PDO::FETCH_ASSOC);
if ($oldLogo && !empty($oldLogo['file_path']) && file_exists($oldLogo['file_path'])) {
unlink($oldLogo['file_path']);
// Supprimer l'entrée de la base
$stmt = $this->db->prepare('DELETE FROM medias WHERE id = ?');
$stmt->execute([$oldLogo['id']]);
}
// Enregistrer dans la table medias
$stmt = $this->db->prepare('
INSERT INTO medias (
support, support_id, fichier, file_type, file_category,
file_size, mime_type, original_name, fk_entite,
file_path, original_width, original_height,
processed_width, processed_height, is_processed,
description, created_at, fk_user_creat
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)
');
$stmt->execute([
'entite', // support
$entiteId, // support_id
$fileName, // fichier
$extension, // file_type
'logo', // file_category
filesize($filePath), // file_size
$mimeType, // mime_type
$uploadedFile['name'], // original_name
$entiteId, // fk_entite
$filePath, // file_path
$originalWidth, // original_width
$originalHeight, // original_height
$newWidth, // processed_width
$newHeight, // processed_height
1, // is_processed
'Logo de l\'entité', // description
$userId // fk_user_creat
]);
$mediaId = $this->db->lastInsertId();
LogService::log('Upload de logo réussi', [
'level' => 'info',
'userId' => $userId,
'entiteId' => $entiteId,
'mediaId' => $mediaId,
'fileName' => $fileName,
'originalSize' => "{$originalWidth}x{$originalHeight}",
'newSize' => "{$newWidth}x{$newHeight}"
]);
Response::json([
'status' => 'success',
'message' => 'Logo uploadé avec succès',
'media_id' => $mediaId,
'file_name' => $fileName,
'file_path' => $uploadPath . '/' . $fileName,
'dimensions' => [
'width' => $newWidth,
'height' => $newHeight
]
], 200);
} catch (Exception $e) {
LogService::log('Erreur lors de l\'upload du logo', [
'level' => 'error',
'error' => $e->getMessage(),
'entiteId' => $id
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de l\'upload du logo'
], 500);
}
}
/**
* Récupère le logo d'une entité
*
* @param string $id ID de l'entité
* @return void
*/
public function getLogo(string $id): void {
try {
// Vérifier l'authentification
$userId = Session::getUserId();
if (!$userId) {
Response::json([
'status' => 'error',
'message' => 'Vous devez être connecté pour effectuer cette action'
], 401);
return;
}
$entiteId = (int)$id;
// Récupérer le logo depuis la base
$stmt = $this->db->prepare('
SELECT id, fichier, file_path, file_type, mime_type,
processed_width, processed_height, file_size
FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$stmt->execute(['entite', $entiteId, 'logo']);
$logo = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$logo) {
Response::json([
'status' => 'error',
'message' => 'Aucun logo trouvé pour cette entité'
], 404);
return;
}
if (!file_exists($logo['file_path'])) {
Response::json([
'status' => 'error',
'message' => 'Fichier logo introuvable'
], 404);
return;
}
// Option 1 : Retourner l'image directement (pour une URL séparée)
// header('Content-Type: ' . $logo['mime_type']);
// header('Content-Length: ' . $logo['file_size']);
// header('Cache-Control: public, max-age=86400'); // Cache 24h
// readfile($logo['file_path']);
// Option 2 : Retourner en base64 dans JSON (recommandé pour Flutter)
$imageData = file_get_contents($logo['file_path']);
if ($imageData === false) {
Response::json([
'status' => 'error',
'message' => 'Impossible de lire le fichier logo'
], 500);
return;
}
$base64 = base64_encode($imageData);
$dataUrl = 'data:' . $logo['mime_type'] . ';base64,' . $base64;
Response::json([
'status' => 'success',
'logo' => [
'id' => $logo['id'],
'data_url' => $dataUrl,
'file_name' => $logo['fichier'],
'mime_type' => $logo['mime_type'],
'width' => $logo['processed_width'],
'height' => $logo['processed_height'],
'size' => $logo['file_size']
]
], 200);
} catch (Exception $e) {
LogService::log('Erreur lors de la récupération du logo', [
'level' => 'error',
'error' => $e->getMessage(),
'entiteId' => $id
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération du logo'
], 500);
}
}
}

View File

@@ -64,7 +64,8 @@ class LoginController {
$stmt = $this->db->prepare(
'SELECT
u.id, u.encrypted_email, u.encrypted_user_name, u.encrypted_name, u.user_pass_hash,
u.first_name, u.fk_role, u.fk_entite, u.chk_active, u.sect_name,
u.first_name, u.fk_role, u.fk_entite, u.fk_titre, u.chk_active, u.sect_name,
u.date_naissance, u.date_embauche, u.encrypted_phone, u.encrypted_mobile,
e.id AS entite_id, e.encrypted_name AS entite_encrypted_name,
e.adresse1, e.code_postal, e.ville, e.gps_lat, e.gps_lng, e.chk_active AS entite_chk_active
FROM users u
@@ -432,7 +433,7 @@ class LoginController {
}
}
// Section "clients" supprimée car redondante avec le nouveau groupe "amicales"
// 5. Section clients gérée plus bas pour les super-administrateurs
// 6. Récupérer les membres (users de l'entité du user) si nécessaire
if ($interface === 'admin' && $user['fk_role'] == 2 && !empty($user['fk_entite'])) {
@@ -518,7 +519,8 @@ class LoginController {
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.id = ? AND e.chk_active = 1'
@@ -531,7 +533,8 @@ class LoginController {
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.id != 1 AND e.chk_active = 1'
@@ -583,7 +586,8 @@ class LoginController {
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.fk_type = 1 AND e.chk_active = 1'
@@ -636,12 +640,58 @@ class LoginController {
// Ajout des amicales à la racine de la réponse si disponibles
if (!empty($amicalesData)) {
// Récupérer le logo de l'entité de l'utilisateur si elle existe
$logoData = null;
if (!empty($user['fk_entite'])) {
$logoStmt = $this->db->prepare('
SELECT id, fichier, file_path, file_type, mime_type, processed_width, processed_height
FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$logoStmt->execute(['entite', $user['fk_entite'], 'logo']);
$logo = $logoStmt->fetch(PDO::FETCH_ASSOC);
if ($logo && file_exists($logo['file_path'])) {
// Lire le fichier et l'encoder en base64
$imageData = file_get_contents($logo['file_path']);
if ($imageData !== false) {
$base64 = base64_encode($imageData);
// Format data URL pour usage direct dans Flutter
$dataUrl = 'data:' . $logo['mime_type'] . ';base64,' . $base64;
$logoData = [
'id' => $logo['id'],
'data_url' => $dataUrl, // Image encodée en base64
'file_name' => $logo['fichier'],
'mime_type' => $logo['mime_type'],
'width' => $logo['processed_width'],
'height' => $logo['processed_height']
];
}
}
}
// Si c'est un tableau avec un seul élément, on envoie directement l'objet
// pour que le client reçoive un objet et non un tableau avec un seul objet
if (count($amicalesData) === 1) {
$response['amicale'] = $amicalesData[0];
// Ajouter le logo à l'amicale si disponible
if ($logoData !== null) {
$response['amicale']['logo'] = $logoData;
}
} else {
$response['amicale'] = $amicalesData;
// Pour plusieurs amicales, ajouter le logo à celle de l'utilisateur
if ($logoData !== null && !empty($user['fk_entite'])) {
foreach ($response['amicale'] as &$amicale) {
if ($amicale['id'] == $user['fk_entite']) {
$amicale['logo'] = $logoData;
break;
}
}
}
}
}
@@ -673,7 +723,7 @@ class LoginController {
$response['users_sectors'] = $usersSectorsData;
}
// Section "clients" supprimée car redondante avec le nouveau groupe "amicales"
// 5. Section clients gérée plus bas pour les super-administrateurs
// 9. Récupérer les régions selon le rôle de l'utilisateur
$regionsData = [];

View File

@@ -227,6 +227,26 @@ class UserController {
$role = isset($data['role']) ? (int)$data['role'] : 1;
$entiteId = isset($data['fk_entite']) ? (int)$data['fk_entite'] : 1;
// Récupérer les paramètres de gestion de l'entité
$entiteStmt = $this->db->prepare('
SELECT chk_mdp_manuel, chk_username_manuel, code_postal, ville
FROM entites
WHERE id = ?
');
$entiteStmt->execute([$entiteId]);
$entiteConfig = $entiteStmt->fetch(PDO::FETCH_ASSOC);
if (!$entiteConfig) {
Response::json([
'status' => 'error',
'message' => 'Entité non trouvée'
], 404);
return;
}
$chkMdpManuel = (int)$entiteConfig['chk_mdp_manuel'];
$chkUsernameManuel = (int)$entiteConfig['chk_username_manuel'];
// Vérification des longueurs d'entrée
if (strlen($email) > 75 || strlen($name) > 50) {
Response::json([
@@ -260,9 +280,83 @@ class UserController {
return;
}
// Génération du mot de passe
$password = ApiService::generateSecurePassword();
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
// Gestion du USERNAME selon chk_username_manuel
$encryptedUsername = '';
if ($chkUsernameManuel === 1) {
// Username manuel obligatoire
if (!isset($data['username']) || empty(trim($data['username']))) {
Response::json([
'status' => 'error',
'message' => 'Le nom d\'utilisateur est requis pour cette entité'
], 400);
return;
}
$username = trim(strtolower($data['username']));
// Validation du format du username
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username)) {
Response::json([
'status' => 'error',
'message' => 'Format du nom d\'utilisateur invalide (10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _)'
], 400);
return;
}
$encryptedUsername = ApiService::encryptSearchableData($username);
// Vérification de l'unicité du username
$checkUsernameStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_user_name = ?');
$checkUsernameStmt->execute([$encryptedUsername]);
if ($checkUsernameStmt->fetch()) {
Response::json([
'status' => 'error',
'message' => 'Ce nom d\'utilisateur est déjà utilisé dans GeoSector'
], 409);
return;
}
} else {
// Génération automatique du username
$username = ApiService::generateUserName(
$this->db,
$name,
$entiteConfig['code_postal'] ?? '00000',
$entiteConfig['ville'] ?? 'ville',
10
);
$encryptedUsername = ApiService::encryptSearchableData($username);
}
// Gestion du MOT DE PASSE selon chk_mdp_manuel
$password = '';
$passwordHash = '';
if ($chkMdpManuel === 1) {
// Mot de passe manuel obligatoire
if (!isset($data['password']) || empty($data['password'])) {
Response::json([
'status' => 'error',
'message' => 'Le mot de passe est requis pour cette entité'
], 400);
return;
}
$password = $data['password'];
// Validation du mot de passe (minimum 8 caractères)
if (strlen($password) < 8) {
Response::json([
'status' => 'error',
'message' => 'Le mot de passe doit contenir au moins 8 caractères'
], 400);
return;
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
} else {
// Génération automatique du mot de passe
$password = ApiService::generateSecurePassword();
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
}
// Préparation des champs optionnels
$phone = isset($data['phone']) ? ApiService::encryptData(trim($data['phone'])) : null;
@@ -276,13 +370,13 @@ class UserController {
// Insertion en base de données
$stmt = $this->db->prepare('
INSERT INTO users (
encrypted_email, user_pass_hash, encrypted_name, first_name,
encrypted_email, encrypted_user_name, user_pass_hash, encrypted_name, first_name,
sect_name, encrypted_phone, encrypted_mobile, fk_role,
fk_entite, chk_alert_email, chk_suivi,
date_naissance, date_embauche,
created_at, fk_user_creat, chk_active
) VALUES (
?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?,
?, ?,
@@ -291,6 +385,7 @@ class UserController {
');
$stmt->execute([
$encryptedEmail,
$encryptedUsername,
$passwordHash,
$encryptedName,
$firstName,
@@ -307,21 +402,54 @@ class UserController {
]);
$userId = $this->db->lastInsertId();
// Envoi de l'email avec les identifiants
ApiService::sendEmail($email, $name, 'welcome', ['password' => $password]);
// Envoi des emails séparés pour plus de sécurité
// 1er email : TOUJOURS envoyer l'identifiant (username)
$usernameEmailData = [
'email' => $email,
'username' => $username,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_username', $usernameEmailData);
// 2ème email : Envoyer le mot de passe (toujours, qu'il soit manuel ou généré)
// Attendre un peu entre les deux emails pour éviter qu'ils arrivent dans le mauvais ordre
sleep(1);
$passwordEmailData = [
'email' => $email,
'password' => $password,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_password', $passwordEmailData);
LogService::log('Utilisateur GeoSector créé', [
'level' => 'info',
'createdBy' => $currentUserId,
'newUserId' => $userId,
'email' => $email
'email' => $email,
'username' => $username,
'usernameManual' => $chkUsernameManuel === 1 ? 'oui' : 'non',
'passwordManual' => $chkMdpManuel === 1 ? 'oui' : 'non',
'emailsSent' => '2 emails (username + password)'
]);
Response::json([
// Préparer la réponse avec les informations de connexion si générées automatiquement
$responseData = [
'status' => 'success',
'message' => 'Utilisateur créé avec succès',
'id' => $userId
], 201);
];
// Ajouter le username dans la réponse (toujours, car nécessaire pour la connexion)
$responseData['username'] = $username;
// Ajouter le mot de passe seulement si généré automatiquement
if ($chkMdpManuel === 0) {
$responseData['password'] = $password;
}
Response::json($responseData, 201);
} catch (PDOException $e) {
LogService::log('Erreur lors de la création d\'un utilisateur GeoSector', [
'level' => 'error',
@@ -756,6 +884,106 @@ class UserController {
}
}
public function checkUsername(): void {
Session::requireAuth();
try {
$data = Request::getJson();
// Validation de la présence du username
if (!isset($data['username']) || empty(trim($data['username']))) {
Response::json([
'status' => 'error',
'message' => 'Username requis pour la vérification'
], 400);
return;
}
$username = trim(strtolower($data['username']));
// Validation du format du username
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username)) {
Response::json([
'status' => 'error',
'message' => 'Format invalide : 10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _',
'available' => false
], 400);
return;
}
// Chiffrement du username pour la recherche
$encryptedUsername = ApiService::encryptSearchableData($username);
// Vérification de l'existence dans la base
$stmt = $this->db->prepare('
SELECT id, encrypted_name, fk_entite
FROM users
WHERE encrypted_user_name = ?
LIMIT 1
');
$stmt->execute([$encryptedUsername]);
$existingUser = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existingUser) {
// Username déjà pris - générer des suggestions
$baseName = substr($username, 0, -2); // Enlever les 2 derniers caractères
$suggestions = [];
// Génération de 3 suggestions
$suggestions[] = $username . '_' . rand(10, 99);
$suggestions[] = $baseName . rand(100, 999);
// Suggestion avec l'année courante
$year = date('y');
$suggestions[] = $username . $year;
// Vérifier que les suggestions sont aussi disponibles
$availableSuggestions = [];
foreach ($suggestions as $suggestion) {
$encryptedSuggestion = ApiService::encryptSearchableData($suggestion);
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_user_name = ?');
$checkStmt->execute([$encryptedSuggestion]);
if (!$checkStmt->fetch()) {
$availableSuggestions[] = $suggestion;
}
}
// Si aucune suggestion n'est disponible, en générer d'autres
if (empty($availableSuggestions)) {
for ($i = 0; $i < 3; $i++) {
$randomSuffix = rand(1000, 9999);
$availableSuggestions[] = $baseName . $randomSuffix;
}
}
Response::json([
'status' => 'success',
'available' => false,
'message' => 'Ce nom d\'utilisateur est déjà utilisé',
'suggestions' => array_slice($availableSuggestions, 0, 3)
]);
} else {
// Username disponible
Response::json([
'status' => 'success',
'available' => true,
'message' => 'Nom d\'utilisateur disponible',
'username' => $username
]);
}
} catch (PDOException $e) {
LogService::log('Erreur lors de la vérification du username', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur lors de la vérification'
], 500);
}
}
// Méthodes auxiliaires
private function validateUpdateData(array $data): ?string {
// Validation de l'email

View File

@@ -38,6 +38,7 @@ class Router {
$this->put('users/:id', ['UserController', 'updateUser']);
$this->delete('users/:id', ['UserController', 'deleteUser']);
$this->post('users/:id/reset-password', ['UserController', 'resetPassword']);
$this->post('users/check-username', ['UserController', 'checkUsername']);
$this->post('logout', ['LoginController', 'logout']);
// Routes entités
@@ -45,6 +46,8 @@ class Router {
$this->get('entites/:id', ['EntiteController', 'getEntiteById']);
$this->get('entites/postal/:code', ['EntiteController', 'getEntiteByPostalCode']);
$this->put('entites/:id', ['EntiteController', 'updateEntite']);
$this->post('entites/:id/logo', ['EntiteController', 'uploadLogo']);
$this->get('entites/:id/logo', ['EntiteController', 'getLogo']);
// Routes opérations
$this->get('operations', ['OperationController', 'getOperations']);

View File

@@ -52,6 +52,16 @@ class ApiService {
$mail->Subject = 'Bienvenue sur GEOSECTOR';
$mail->Body = EmailTemplates::getWelcomeTemplate($name, $data['username'] ?? '', $data['password']);
break;
case 'welcome_username':
$mail->Subject = 'GEOSECTOR - Votre identifiant de connexion';
$mail->Body = EmailTemplates::getWelcomeUsernameTemplate($name, $data['username'] ?? '');
break;
case 'welcome_password':
$mail->Subject = 'GEOSECTOR - Votre mot de passe';
$mail->Body = EmailTemplates::getWelcomePasswordTemplate($name, $data['password'] ?? '');
break;
case 'lostpwd':
$mail->Subject = 'Réinitialisation de votre mot de passe GEOSECTOR';

View File

@@ -17,6 +17,59 @@ class EmailTemplates {
L'équipe GeoSector";
}
/**
* Template d'email de bienvenue - Identifiant uniquement
*/
public static function getWelcomeUsernameTemplate(string $name, string $username): string {
return "
Bonjour $name,<br><br>
Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br>
Voici votre identifiant de connexion :<br>
<div style='background:#f5f5f5; padding:15px; margin:20px 0; border-left:4px solid #007bff;'>
<b style='font-size:16px;'>Identifiant :</b> <span style='font-size:18px; color:#333;'>$username</span>
</div>
<p style='color:#666; font-size:14px;'>
<b>Important :</b> Conservez précieusement cet identifiant, vous en aurez besoin pour vous connecter.
</p>
<p>
Votre mot de passe vous sera communiqué dans un email séparé pour des raisons de sécurité.
</p>
<p>
Une fois que vous aurez reçu votre mot de passe, vous pourrez vous connecter sur
<a href=\"https://app.geosector.fr\" style='color:#007bff;'>app.geosector.fr</a>
</p>
<br>
À très bientôt,<br>
L'équipe GeoSector";
}
/**
* Template d'email de bienvenue - Mot de passe uniquement
*/
public static function getWelcomePasswordTemplate(string $name, string $password): string {
return "
Bonjour $name,<br><br>
Suite à la création de votre compte <b>GeoSector</b>, voici votre mot de passe :<br><br>
<div style='background:#f5f5f5; padding:15px; margin:20px 0; border-left:4px solid #28a745;'>
<b style='font-size:16px;'>Mot de passe :</b> <span style='font-family:monospace; font-size:18px; color:#333;'>$password</span>
</div>
<p style='color:#d73502; font-size:14px;'>
<b>⚠ Sécurité :</b> Pour garantir la sécurité de votre compte, nous vous recommandons
de conserver ce mot de passe en lieu sûr et de ne jamais le partager.
</p>
<p>
Vous pouvez maintenant vous connecter avec votre identifiant (reçu dans un email précédent)
et ce mot de passe sur <a href=\"https://app.geosector.fr\" style='color:#007bff;'>app.geosector.fr</a>
</p>
<p style='background:#fff3cd; padding:10px; border-radius:5px; margin-top:20px;'>
<b>Rappel :</b> Ne communiquez jamais votre mot de passe à un tiers. L'équipe GeoSector
ne vous demandera jamais votre mot de passe par email ou téléphone.
</p>
<br>
À très bientôt,<br>
L'équipe GeoSector";
}
/**
* Template d'email pour mot de passe perdu
*/