diff --git a/.github/workflows/frontend-client-build.yml b/.github/workflows/frontend-client-build.yml index 9bb80fe0..dfd1e956 100644 --- a/.github/workflows/frontend-client-build.yml +++ b/.github/workflows/frontend-client-build.yml @@ -1,10 +1,14 @@ -name: Frontend Client - EAS Update +name: Frontend Client - EAS Build on: push: branches: [main] paths: - "mobile/**" + pull_request: + branches: [main] + paths: + - "mobile/**" jobs: typecheck: @@ -28,7 +32,7 @@ jobs: working-directory: mobile run: npx tsc --noEmit - eas-update: + build-apk-prod: needs: typecheck runs-on: ubuntu-latest @@ -58,10 +62,25 @@ jobs: jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json mv app.tmp.json app.json - - name: Map channel to branch + - name: Debug app.json working-directory: mobile - run: eas channel:edit production --branch main --non-interactive + run: cat app.json - - name: Publish OTA update + - name: Build production APK working-directory: mobile - run: eas update --channel production --platform android --message "${{ github.event.head_commit.message }}" --non-interactive + env: + EAS_BUILD_NO_EXPO_GO_WARNING: true + run: eas build --platform android --profile production --non-interactive + + - name: Download production APK + working-directory: mobile + run: | + APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl') + curl -L -o client-panel-prod.apk "$APK_URL" + + - name: Upload production APK artifact + uses: actions/upload-artifact@v4 + with: + name: client-panel-android-prod-apk + path: mobile/client-panel-prod.apk + retention-days: 14 diff --git a/afaire.txt b/afaire.txt index e31ae7e6..9560ce14 100644 --- a/afaire.txt +++ b/afaire.txt @@ -1,3 +1,2 @@ -suppr la page register rajouter la modification de mot de passe les notif sur l'app web diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go index dabef00e..710e5e6f 100644 --- a/backend/gestion/db/db_clients.go +++ b/backend/gestion/db/db_clients.go @@ -178,6 +178,28 @@ func (d *Database) UpdateClientPassword(clientID int, hashedPassword string) err return nil } +// UpdateClientPasswordAndClearFlag met à jour le mot de passe et remet must_change_password à false +func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword string) error { + query := `UPDATE clients SET password = $1, must_change_password = FALSE, updated_at = CURRENT_TIMESTAMP WHERE id = $2` + + result, err := d.Exec(query, hashedPassword, clientID) + if err != nil { + return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("erreur lors de la vérification: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("client non trouvé") + } + + log.Printf("✅ Mot de passe client mis à jour + must_change_password=false (ID: %d)", clientID) + return nil +} + // GetClientStats récupère les statistiques d'un client func (d *Database) GetClientStats(clientID int) (map[string]interface{}, error) { client, err := d.GetClientByID(clientID) @@ -369,7 +391,7 @@ func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error // GetClientByUsername récupère un client par son username func (d *Database) GetClientByUsername(username string) (*models.Client, error) { client := &models.Client{} - query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at + query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, must_change_password, created_at FROM clients WHERE username = $1` err := d.QueryRow(query, username).Scan( @@ -383,6 +405,7 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error) &client.Point, &client.PointZipette, &client.Amende, + &client.MustChangePassword, &client.CreatedAt, ) diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 08592a9b..a2649e35 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -71,6 +71,11 @@ func InitDB() *Database { log.Println("✅ Tables créées avec succès") + // Migration: ajouter colonne must_change_password si elle n'existe pas + if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT TRUE`); err != nil { + log.Fatalf("❌ Erreur migration must_change_password: %v", err) + } + // Lancer le nettoyage périodique des tokens expirés go database.cleanExpiredTokensPeriodically() diff --git a/backend/gestion/handlers/auth.go b/backend/gestion/handlers/auth.go index 59c7b6b3..d1a2c0c9 100644 --- a/backend/gestion/handlers/auth.go +++ b/backend/gestion/handlers/auth.go @@ -327,17 +327,63 @@ func LoginClient(c *gin.Context) { TokenType: "Bearer", ExpiresIn: int(clientTokenDuration.Seconds()), User: gin.H{ - "id": client.ID, - "username": client.Username, - "nom": client.Nom, - "prenom": client.Prenom, - "telephone": client.Telephone, - "role": "client", - "session_id": sessionID, + "id": client.ID, + "username": client.Username, + "nom": client.Nom, + "prenom": client.Prenom, + "telephone": client.Telephone, + "role": "client", + "session_id": sessionID, + "must_change_password": client.MustChangePassword, }, }) } +// ChangePassword permet à un client de changer son mot de passe +// PUT /api/v1/auth/change-password +func ChangePassword(c *gin.Context) { + var req struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides", "details": err.Error()}) + return + } + + clientID := c.GetInt("client_id") + database := c.MustGet("database").(*db.Database) + + client, err := database.GetClientByID(clientID) + if err != nil { + log.Printf("❌ [CHANGE_PASSWORD] Client non trouvé: ID=%d", clientID) + c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.CurrentPassword)); err != nil { + log.Printf("❌ [CHANGE_PASSWORD] Mot de passe actuel invalide: ID=%d", clientID) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Mot de passe actuel incorrect"}) + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + log.Printf("❌ [CHANGE_PASSWORD] Erreur bcrypt: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"}) + return + } + + if err := database.UpdateClientPasswordAndClearFlag(clientID, string(hashed)); err != nil { + log.Printf("❌ [CHANGE_PASSWORD] Erreur update: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour mot de passe"}) + return + } + + log.Printf("✅ [CHANGE_PASSWORD] Mot de passe changé: ID=%d", clientID) + c.JSON(http.StatusOK, gin.H{"success": true, "message": "Mot de passe mis à jour avec succès"}) +} + // LogoutClient déconnecte un client // POST /api/v1/auth/logout func LogoutClient(c *gin.Context) { diff --git a/backend/gestion/handlers/notifications.go b/backend/gestion/handlers/notifications.go new file mode 100644 index 00000000..922773a4 --- /dev/null +++ b/backend/gestion/handlers/notifications.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "encoding/json" + "gestion/db" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetClientNotifications retourne les notifications du client connecté +// GET /api/v1/notifications +func GetClientNotifications(c *gin.Context) { + username := c.GetString("username") + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + notifKey := "notifications:" + username + + // Récupérer toutes les notifications (max 50) + results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result() + if err != nil { + log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"}) + return + } + + type Notification struct { + CommandID int `json:"command_id"` + Type string `json:"type"` + Message string `json:"message"` + CreatedAt string `json:"created_at"` + Read bool `json:"read"` + } + + notifications := make([]Notification, 0, len(results)) + unreadCount := 0 + + for _, raw := range results { + var n Notification + if err := json.Unmarshal([]byte(raw), &n); err != nil { + continue + } + notifications = append(notifications, n) + if !n.Read { + unreadCount++ + } + } + + log.Printf("✅ [GET_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount) + + c.JSON(http.StatusOK, gin.H{ + "notifications": notifications, + "unread_count": unreadCount, + "total": len(notifications), + }) +} + +// MarkNotificationsRead marque toutes les notifications comme lues +// POST /api/v1/notifications/read +func MarkNotificationsRead(c *gin.Context) { + username := c.GetString("username") + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + notifKey := "notifications:" + username + + // Récupérer toutes les notifications + results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result() + if err != nil { + log.Printf("❌ [MARK_NOTIFICATIONS_READ] Erreur Redis LRange: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"}) + return + } + + // Réécrire chaque notification avec read=true + markedCount := 0 + for i, raw := range results { + var n map[string]interface{} + if err := json.Unmarshal([]byte(raw), &n); err != nil { + continue + } + if read, ok := n["read"].(bool); ok && read { + continue + } + n["read"] = true + updated, _ := json.Marshal(n) + db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated)) + markedCount++ + } + + log.Printf("✅ [MARK_NOTIFICATIONS_READ] %d notifications marquées lues pour %s", markedCount, username) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "marked_count": markedCount, + }) +} diff --git a/backend/gestion/models/client.go b/backend/gestion/models/client.go index 747b20d0..48f60d30 100644 --- a/backend/gestion/models/client.go +++ b/backend/gestion/models/client.go @@ -14,7 +14,8 @@ type Client struct { Point int `json:"point"` PointZipette int `json:"points_zipette"` Amende float64 `json:"amende"` - CancellationsCount int `json:"cancellations_count"` // ✅ NOUVEAU - LastPenaltyReason string `json:"last_penalty_reason"` - CreatedAt time.Time `json:"created_at"` + CancellationsCount int `json:"cancellations_count"` // ✅ NOUVEAU + LastPenaltyReason string `json:"last_penalty_reason"` + MustChangePassword bool `json:"must_change_password"` + CreatedAt time.Time `json:"created_at"` } diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index e9ec0710..4d596348 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -37,6 +37,13 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services authGroupV1.POST("/logout", handlers.LogoutClient) } + // Route change-password (auth client requise) + authProtectedV1 := router.Group("/api/v1/auth") + authProtectedV1.Use(middleware.ClientMiddleware) + { + authProtectedV1.PUT("/change-password", handlers.ChangePassword) + } + // ============================================ // 📦 PRODUITS (v1) - PUBLIC (SANS middleware!) // ============================================ @@ -84,6 +91,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ⭐⭐ PÉNALITÉS CLIENT cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités + // 🔔 NOTIFICATIONS CLIENT + cartGroupV1.GET("/notifications", handlers.GetClientNotifications) + cartGroupV1.POST("/notifications/read", handlers.MarkNotificationsRead) + // 👤 PROFIL CLIENT - MODIFICATION PAR LE CLIENT cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil } diff --git a/mobile/src/components/ui/Toast.tsx b/mobile/src/components/ui/Toast.tsx index ca9e7b6c..d3ddc301 100644 --- a/mobile/src/components/ui/Toast.tsx +++ b/mobile/src/components/ui/Toast.tsx @@ -82,7 +82,7 @@ export default function Toast({ ); } - +// const styles = StyleSheet.create({ container: { position: "absolute",