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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
25
api/scripts/sql/add_chk_username_manuel.sql
Normal file
25
api/scripts/sql/add_chk_username_manuel.sql
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user