diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go index cefd85e6..e928afef 100644 --- a/backend/gestion/db/db_clients.go +++ b/backend/gestion/db/db_clients.go @@ -374,10 +374,12 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error) MustChangePassword bool `gorm:"column:must_change_password"` PointsExtraJSON []byte `gorm:"column:points_extra"` CreatedAt time.Time `gorm:"column:created_at"` + TwoFAEnabled bool `gorm:"column:two_fa_enabled"` } err := d.GDB.Raw(` SELECT id, username, password, nom, prenom, telephone, command, amende, - must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at + must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at, + two_fa_enabled FROM clients WHERE username = ?`, username).Scan(&row).Error if err != nil { return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err) @@ -397,6 +399,7 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error) Amende: row.Amende, MustChangePassword: row.MustChangePassword, CreatedAt: row.CreatedAt, + TwoFAEnabled: row.TwoFAEnabled, } client.PointsExtra = map[string]int{} if len(row.PointsExtraJSON) > 0 { @@ -406,6 +409,10 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error) return client, nil } +func (d *Database) SetClientTwoFAEnabled(clientID int, enabled bool) error { + return d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("two_fa_enabled", enabled).Error +} + func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) { amende, err := d.GetClientAmende(username) if err != nil { diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index 508df618..54bbb3db 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -66,6 +66,7 @@ func DefaultSettings() models.AppSettings { }, }, }, + ShopName: "Milieu-Nantais", DeliveryMode: models.DeliveryModeConfig{ Mode: "single", CategoryRoutes: []models.CategoryRoute{}, @@ -81,6 +82,7 @@ func DefaultSettings() models.AppSettings { "44860", "44220", "44118", "44710", "44690", "44119", }}, }, + Telegram2FAEnabled: false, } } @@ -149,6 +151,10 @@ func (d *Database) GetSettings() (models.AppSettings, error) { if err := json.Unmarshal([]byte(row.Value), &mode); err == nil { settings.DeliveryMode = mode } + case "telegram_2fa_enabled": + settings.Telegram2FAEnabled = row.Value == "true" + case "shop_name": + settings.ShopName = row.Value } } return settings, nil @@ -226,7 +232,9 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { {"telegram_bot_token", s.TelegramBotToken}, {"telegram_bot_username", s.TelegramBotUsername}, {"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)}, + {"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)}, {"delivery_mode", string(deliveryModeJSON)}, + {"shop_name", s.ShopName}, } upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) diff --git a/backend/gestion/db/db_telegram.go b/backend/gestion/db/db_telegram.go index c8ecef2b..37c5863f 100644 --- a/backend/gestion/db/db_telegram.go +++ b/backend/gestion/db/db_telegram.go @@ -15,6 +15,7 @@ func (d *Database) MigrateAddTelegramColumns() { migrations := []string{ `ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`, + `ALTER TABLE clients ADD COLUMN IF NOT EXISTS two_fa_enabled BOOLEAN NOT NULL DEFAULT FALSE`, } for _, q := range migrations { if err := d.GDB.Exec(q).Error; err != nil { @@ -127,3 +128,36 @@ func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, return "", "", fmt.Errorf("aucun compte lié à ce chat_id") } + +// ── 2FA sessions ───────────────────────────────────────────────────────────── + +const twoFASessionTTL = 5 * time.Minute + +type twoFASessionData struct { + Username string `json:"username"` + Code string `json:"code"` +} + +func Store2FASession(sessionToken, username, code string) error { + data, err := json.Marshal(twoFASessionData{Username: username, Code: code}) + if err != nil { + return err + } + return Redis.Set(RedisCtx, "2fa:session:"+sessionToken, data, twoFASessionTTL).Err() +} + +// Verify2FASession valide le code et retourne le username. GETDEL = atomique (anti-replay). +func Verify2FASession(sessionToken, code string) (string, error) { + val, err := Redis.GetDel(RedisCtx, "2fa:session:"+sessionToken).Bytes() + if err != nil { + return "", fmt.Errorf("session invalide ou expirée") + } + var d twoFASessionData + if err := json.Unmarshal(val, &d); err != nil { + return "", fmt.Errorf("données corrompues") + } + if d.Code != code { + return "", fmt.Errorf("code incorrect") + } + return d.Username, nil +} diff --git a/backend/gestion/handlers/auth.go b/backend/gestion/handlers/auth.go index e22c49c4..6b351778 100644 --- a/backend/gestion/handlers/auth.go +++ b/backend/gestion/handlers/auth.go @@ -1,8 +1,11 @@ package handlers import ( + "crypto/rand" + "fmt" "gestion/db" "gestion/models" + "gestion/services" "gestion/utils" "log" "net/http" @@ -76,7 +79,7 @@ func RegisterClient(c *gin.Context) { if err := c.ShouldBindJSON(&req); err != nil { log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err) c.JSON(http.StatusBadRequest, gin.H{ - "error": "Données invalides", + "error": "Données invalides", }) return } @@ -242,8 +245,16 @@ func AdminCreateClient(c *gin.Context) { }) } +func cryptoRandInt() int { + b := make([]byte, 4) + rand.Read(b) + return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) +} + // LoginClient authentifie un client func LoginClient(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + var req models.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err) @@ -251,8 +262,6 @@ func LoginClient(c *gin.Context) { return } - database := c.MustGet("database").(*db.Database) - client, err := database.GetClientByUsername(req.Username) if err != nil || client == nil { log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username) @@ -266,6 +275,30 @@ func LoginClient(c *gin.Context) { return } + settings, err := database.GetSettings() + if err != nil { + log.Printf("❌ [LOGIN_CLIENT] Erreur récupération des paramètres: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"}) + return + } + + if settings.Telegram2FAEnabled && client.TwoFAEnabled { + chatID, linked, _ := database.GetClientTelegramChatID(client.Username) + if linked { + code := fmt.Sprintf("%06d", cryptoRandInt()%1000000) + sessionToken := uuid.New().String() + if err := db.Store2FASession(sessionToken, client.Username, code); err == nil { + msg := fmt.Sprintf("🔐 Code de vérification : %s\n\nValable 5 minutes.", code) + services.TelegramBot.SendMessage(chatID, msg) + c.JSON(http.StatusOK, gin.H{ + "requires_2fa": true, + "session_token": sessionToken, + }) + return + } + } + } + token, err := generateClientToken(client) if err != nil { log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err) @@ -280,7 +313,6 @@ func LoginClient(c *gin.Context) { return } - // Créer la session Redis sessionID := uuid.New().String() if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil { log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err) @@ -303,6 +335,125 @@ func LoginClient(c *gin.Context) { }) } +func Verify2FAClient(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + var req struct { + SessionToken string `json:"session_token" binding:"required"` + Code string `json:"code" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"}) + return + } + + username, err := db.Verify2FASession(req.SessionToken, req.Code) + if err != nil { + log.Printf("❌ [2FA] Échec vérification: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + client, err := database.GetClientByUsername(username) + if err != nil || client == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"}) + return + } + + token, err := generateClientToken(client) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"}) + return + } + + expiresAt := time.Now().Add(clientTokenDuration) + if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"}) + return + } + + sessionID := uuid.New().String() + if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil { + log.Printf("⚠️ [2FA] Erreur session Redis: %v", err) + } + + c.JSON(http.StatusOK, models.LoginResponse{ + AccessToken: token, + 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, + "must_change_password": client.MustChangePassword, + }, + }) +} + +func GetClient2FAStatus(c *gin.Context) { + clientID := c.GetInt("client_id") + database := c.MustGet("database").(*db.Database) + + client, err := database.GetClientByID(clientID) + if err != nil || client == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"}) + return + } + + _, tgLinked, _ := database.GetClientTelegramChatID(client.Username) + + settings, _ := database.GetSettings() + + c.JSON(http.StatusOK, gin.H{ + "two_fa_enabled": client.TwoFAEnabled, + "telegram_linked": tgLinked, + "admin_2fa_enabled": settings.Telegram2FAEnabled, + }) +} + +func ToggleClient2FA(c *gin.Context) { + clientID := c.GetInt("client_id") + database := c.MustGet("database").(*db.Database) + + var req struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"}) + return + } + + client, err := database.GetClientByID(clientID) + if err != nil || client == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"}) + return + } + + if req.Enabled { + _, linked, _ := database.GetClientTelegramChatID(client.Username) + if !linked { + c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram non lié — impossible d'activer la 2FA"}) + return + } + settings, _ := database.GetSettings() + if !settings.Telegram2FAEnabled { + c.JSON(http.StatusBadRequest, gin.H{"error": "La 2FA n'est pas activée par l'administrateur"}) + return + } + } + + if err := database.SetClientTwoFAEnabled(clientID, req.Enabled); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "two_fa_enabled": req.Enabled}) +} + // ChangePassword permet à un client de changer son mot de passe func ChangePassword(c *gin.Context) { var req struct { diff --git a/backend/gestion/handlers/settings.go b/backend/gestion/handlers/settings.go index c78a1aaf..bd7c50d6 100644 --- a/backend/gestion/handlers/settings.go +++ b/backend/gestion/handlers/settings.go @@ -30,20 +30,22 @@ func GetPublicSettings(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "success": true, - "penalties_enabled": settings.PenaltiesEnabled, - "show_amende_score": settings.ShowAmendeScore, - "points_enabled": settings.PointsEnabled, - "points_separated": len(settings.PointsPools) > 1, - "pool_names": poolNames, - "pool_keys": poolKeys, - "referral_enabled": settings.ReferralEnabled, - "referral_amount": settings.ReferralAmount, - "delivery_schedule": settings.DeliverySchedule, - "crypto_payment_enabled": settings.CryptoPaymentEnabled, - "crypto_only": settings.CryptoOnly, - "nowpayments_currencies": settings.NowPaymentsCurrencies, - "telegram_notifications_enabled": settings.TelegramNotificationsEnabled, + "success": true, + "penalties_enabled": settings.PenaltiesEnabled, + "show_amende_score": settings.ShowAmendeScore, + "points_enabled": settings.PointsEnabled, + "points_separated": len(settings.PointsPools) > 1, + "pool_names": poolNames, + "pool_keys": poolKeys, + "referral_enabled": settings.ReferralEnabled, + "referral_amount": settings.ReferralAmount, + "delivery_schedule": settings.DeliverySchedule, + "crypto_payment_enabled": settings.CryptoPaymentEnabled, + "crypto_only": settings.CryptoOnly, + "nowpayments_currencies": settings.NowPaymentsCurrencies, + "telegram_notifications_enabled": settings.TelegramNotificationsEnabled, + "shop_name": settings.ShopName, + "two_fa_enabled": settings.Telegram2FAEnabled, }) } diff --git a/backend/gestion/handlers/telegram.go b/backend/gestion/handlers/telegram.go index fcf94917..5a1062d9 100644 --- a/backend/gestion/handlers/telegram.go +++ b/backend/gestion/handlers/telegram.go @@ -242,13 +242,20 @@ func UnlinkClientTelegram(c *gin.Context) { return } + clientID := c.GetInt("client_id") database := c.MustGet("database").(*db.Database) + if err := database.DeleteClientTelegramChatID(username); err != nil { log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"}) return } + // Désactiver la 2FA si Telegram est délié + if clientID > 0 { + _ = database.SetClientTwoFAEnabled(clientID, false) + } + log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username) c.JSON(http.StatusOK, gin.H{"success": true}) } diff --git a/backend/gestion/models/client.go b/backend/gestion/models/client.go index 6f302fc2..e2d2f1b1 100644 --- a/backend/gestion/models/client.go +++ b/backend/gestion/models/client.go @@ -21,6 +21,7 @@ type Client struct { Parrain string `gorm:"column:parrain" json:"parrain"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + TwoFAEnabled bool `gorm:"column:two_fa_enabled;default:false" json:"two_fa_enabled"` } func (Client) TableName() string { return "clients" } diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index 933a601d..48cc3b7a 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -61,22 +61,24 @@ type DeliveryModeConfig struct { // AppSettings contient les paramètres globaux de l'application type AppSettings struct { - PenaltiesEnabled bool `json:"penalties_enabled"` - ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine - PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable) - PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points - PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés - ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage - ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage - CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto - CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces) - NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments - NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments - NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) - DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour - PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande - TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) - TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) - TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram - DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs + PenaltiesEnabled bool `json:"penalties_enabled"` + ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine + PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable) + PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points + PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés + ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage + ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage + CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto + CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces) + NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments + NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments + NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) + DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour + PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande + TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) + TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) + TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram + DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs + ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client + Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs } diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 3a06b49f..2507b66b 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -34,6 +34,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services { authGroupV1.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginClient) authGroupV1.POST("/logout", handlers.LogoutClient) + authGroupV1.POST("/2fa/verify", middleware.LoginRateLimitMiddleware, handlers.Verify2FAClient) } // Route change-password (auth client requise) @@ -107,6 +108,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services cartGroupV1.GET("/profile", handlers.GetMyProfile) // ✅ Récupérer mon profil cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil + // 🔐 2FA CLIENT + cartGroupV1.GET("/two-fa/status", handlers.GetClient2FAStatus) + cartGroupV1.POST("/two-fa/toggle", handlers.ToggleClient2FA) + // 🎁 PARRAINAGE CLIENT cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance) cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo) diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 0b070aef..63877bad 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -958,7 +958,9 @@ export interface AppSettings { telegram_bot_token: string; telegram_bot_username: string; telegram_notifications_enabled: boolean; + telegram_2fa_enabled: boolean; delivery_mode: DeliveryModeConfig; + shop_name: string; } export const getSettings = async (): Promise<{ diff --git a/frontend-admin/src/screens/admin/SettingsScreen.tsx b/frontend-admin/src/screens/admin/SettingsScreen.tsx index 0e792e32..7d266512 100644 --- a/frontend-admin/src/screens/admin/SettingsScreen.tsx +++ b/frontend-admin/src/screens/admin/SettingsScreen.tsx @@ -677,7 +677,9 @@ export default function SettingsScreen() { telegram_bot_token: "", telegram_bot_username: "", telegram_notifications_enabled: false, + telegram_2fa_enabled: false, delivery_mode: { mode: "single" as const, category_routes: [] }, + shop_name: "Milieu-Nantais", }); const [showApiKey, setShowApiKey] = useState(false); const [showIpnSecret, setShowIpnSecret] = useState(false); @@ -939,6 +941,27 @@ export default function SettingsScreen() { return ( + {/* Personnalisation */} + + Personnalisation + + + Nom du shop + Affiché dans la sidebar du site client + + + + setSettings((p) => ({ ...p, shop_name: v }))} + placeholder="Ex: Milieu-Nantais" + placeholderTextColor={colors.textMuted} + autoCorrect={false} + /> + + + {/* Amendes */} Amendes @@ -1412,6 +1435,18 @@ export default function SettingsScreen() { thumbColor="#fff" /> + + + Authentification 2FA + Permettre aux clients d'activer la double authentification via Telegram lors de la connexion + + setSettings((prev) => ({ ...prev, telegram_2fa_enabled: v }))} + trackColor={{ false: colors.border, true: colors.accent }} + thumbColor="#fff" + /> + Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte. diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts index 9bdae554..62a557c0 100644 --- a/frontend-prep/src/api/api.ts +++ b/frontend-prep/src/api/api.ts @@ -41,6 +41,8 @@ export interface AuthResponse { access_token?: string; // ✅ CRITICAL! token_type?: string; expires_in?: number; + requires_2fa?: boolean; + session_token?: string; user?: { id: number; username: string; @@ -210,6 +212,15 @@ export const loginUser = async ( const data = await safeJson(response); console.log("📋 [LOGIN] Réponse:", data); + // 2FA requis — retourner sans token + if (data.requires_2fa) { + return { + success: true, + requires_2fa: true, + session_token: data.session_token, + }; + } + // ✅ Vérifier access_token if (!data.access_token) { console.error("❌ [LOGIN] Pas de access_token"); @@ -1866,6 +1877,8 @@ export interface PublicSettings { crypto_payment_enabled: boolean; crypto_only: boolean; nowpayments_currencies: string[]; + shop_name: string; + two_fa_enabled: boolean; } export const getPublicSettings = async (): Promise => { @@ -1880,6 +1893,8 @@ export const getPublicSettings = async (): Promise => { crypto_payment_enabled: false, crypto_only: false, nowpayments_currencies: [], + shop_name: "Milieu-Nantais", + two_fa_enabled: false, }; try { const response = await fetch(`${API_URL}/app-settings`); @@ -1901,12 +1916,71 @@ export const getPublicSettings = async (): Promise => { nowpayments_currencies: Array.isArray(data.nowpayments_currencies) ? data.nowpayments_currencies : [], + shop_name: data.shop_name || "Milieu-Nantais", + two_fa_enabled: data.two_fa_enabled ?? false, }; } catch { return defaults; } }; +export const verify2FA = async ( + sessionToken: string, + code: string, +): Promise => { + try { + const response = await fetch(`${API_URL}/auth/2fa/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_token: sessionToken, code }), + }); + const data = await safeJson(response); + if (!response.ok) { + return { success: false, message: data.error || "Code invalide" }; + } + sessionStorage.setItem("token", data.access_token); + syncUsernameFromJWT(); + return { + success: true, + access_token: data.access_token, + token_type: data.token_type, + expires_in: data.expires_in, + user: data.user, + }; + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Erreur" }; + } +}; + +export const get2FAStatus = async (): Promise<{ two_fa_enabled: boolean; telegram_linked: boolean; admin_2fa_enabled: boolean }> => { + const token = sessionStorage.getItem("token"); + try { + const response = await fetch(`${API_URL}/two-fa/status`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) return { two_fa_enabled: false, telegram_linked: false, admin_2fa_enabled: false }; + return await safeJson(response); + } catch { + return { two_fa_enabled: false, telegram_linked: false, admin_2fa_enabled: false }; + } +}; + +export const toggle2FA = async (enabled: boolean): Promise<{ success: boolean; error?: string }> => { + const token = sessionStorage.getItem("token"); + try { + const response = await fetch(`${API_URL}/two-fa/toggle`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ enabled }), + }); + const data = await safeJson(response); + if (!response.ok) return { success: false, error: data.error }; + return { success: true }; + } catch { + return { success: false, error: "Erreur réseau" }; + } +}; + export interface CryptoPaymentStatus { command_id: number; client_order_number?: number; diff --git a/frontend-prep/src/api/api_types.ts b/frontend-prep/src/api/api_types.ts index ff8a1728..fac2599f 100644 --- a/frontend-prep/src/api/api_types.ts +++ b/frontend-prep/src/api/api_types.ts @@ -58,6 +58,8 @@ export interface LoginResponse { token_type?: string; expires_in?: number; user?: UserResponse; + requires_2fa?: boolean; + session_token?: string; } /** diff --git a/frontend-prep/src/components/Navbar.tsx b/frontend-prep/src/components/Navbar.tsx index c7b39a04..ca30253e 100644 --- a/frontend-prep/src/components/Navbar.tsx +++ b/frontend-prep/src/components/Navbar.tsx @@ -39,6 +39,7 @@ function Navbar() { const [unreadCount, setUnreadCount] = useState(0); const [showNotifPanel, setShowNotifPanel] = useState(false); const [referralEnabled, setReferralEnabled] = useState(true); + const [shopName, setShopName] = useState("Milieu-Nantais"); const seenKeysRef = useRef>(new Set()); const isFirstLoadRef = useRef(true); const notifPanelRef = useRef(null); @@ -74,7 +75,10 @@ function Navbar() { }, [fetchNotifications]); useEffect(() => { - getPublicSettings().then((s) => setReferralEnabled(s.referral_enabled)); + getPublicSettings().then((s) => { + setReferralEnabled(s.referral_enabled); + if (s.shop_name) setShopName(s.shop_name); + }); }, []); useEffect(() => { @@ -235,7 +239,7 @@ function Navbar() {
-

Milieu-Nantais

+

{shopName}

Mon espace

diff --git a/frontend-prep/src/pages/LoginClient/Login.tsx b/frontend-prep/src/pages/LoginClient/Login.tsx index bad91717..f01d9f63 100644 --- a/frontend-prep/src/pages/LoginClient/Login.tsx +++ b/frontend-prep/src/pages/LoginClient/Login.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { Lock, Mail, Eye, EyeOff, User } from "lucide-react"; +import { Lock, Mail, Eye, EyeOff, User, Shield } from "lucide-react"; import "./Login.css"; -import { loginUser, syncUsernameFromJWT } from "../../api/api"; +import { loginUser, verify2FA, syncUsernameFromJWT } from "../../api/api"; import type { LoginRequest } from "../../api/api_types"; import { useNavigate } from "react-router-dom"; @@ -21,6 +21,10 @@ const LoginClient = () => { const [isLoading, setIsLoading] = useState(false); const [apiError, setApiError] = useState(""); + const [twoFAStep, setTwoFAStep] = useState(false); + const [sessionToken, setSessionToken] = useState(""); + const [twoFACode, setTwoFACode] = useState(""); + /** * ✅ Valider le formulaire */ @@ -79,30 +83,19 @@ const LoginClient = () => { hasToken: !!result.access_token, }); - if (result.success && result.access_token) { - console.log("✅ [LOGIN] Connexion réussie!"); - - // ✅ La synchronisation JWT est AUTOMATIQUE dans loginUser() - // Pas besoin de le faire ici - console.log("✅ [LOGIN] Token et username synchronisés"); - - // ✅ Vérifier la synchronisation + if (result.success && result.requires_2fa) { + setSessionToken(result.session_token || ""); + setTwoFAStep(true); + } else if (result.success && result.access_token) { const syncedUsername = syncUsernameFromJWT(); console.log("✅ [LOGIN] Username synchronisé:", syncedUsername); - - // ✅ Redirection if (result.user?.must_change_password) { - console.log("✅ [LOGIN] Première connexion - changement de mot de passe requis"); navigate("/user/change-password"); } else { - console.log("✅ [LOGIN] Redirection vers /user/accueil"); navigate("/user/accueil"); } } else { - // ❌ Erreur API - const errorMessage = - result.message || "Identifiants incorrects"; - console.error("❌ [LOGIN] Erreur API:", errorMessage); + const errorMessage = result.message || "Identifiants incorrects"; setApiError(errorMessage); setErrors({ username: errorMessage }); } @@ -117,6 +110,30 @@ const LoginClient = () => { } }; + const handle2FASubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!twoFACode.trim()) return; + setIsLoading(true); + setApiError(""); + try { + const result = await verify2FA(sessionToken, twoFACode.trim()); + if (result.success && result.access_token) { + syncUsernameFromJWT(); + if (result.user?.must_change_password) { + navigate("/user/change-password"); + } else { + navigate("/user/accueil"); + } + } else { + setApiError(result.message || "Code invalide"); + } + } catch { + setApiError("Erreur de vérification"); + } finally { + setIsLoading(false); + } + }; + /** * ✅ Gérer les changements d'input */ @@ -141,6 +158,66 @@ const LoginClient = () => { } }; + if (twoFAStep) { + return ( +
+
+
+
+ +
+

Vérification 2FA

+

+ Entrez le code envoyé sur votre Telegram +

+
+
+
+ {apiError && ( +
⚠️ {apiError}
+ )} +
+ +
+ setTwoFACode(e.target.value)} + className="form-input" + placeholder="000000" + maxLength={6} + disabled={isLoading} + autoComplete="one-time-code" + style={{ letterSpacing: "0.3em", textAlign: "center", fontSize: "1.5rem" }} + /> +
+
+ + +
+
+
+
+ ); + } + return (
diff --git a/frontend-prep/src/pages/User/ProfilePage.tsx b/frontend-prep/src/pages/User/ProfilePage.tsx index ef3fd2ee..2e4710fb 100644 --- a/frontend-prep/src/pages/User/ProfilePage.tsx +++ b/frontend-prep/src/pages/User/ProfilePage.tsx @@ -1,11 +1,11 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import Navbar from '../../components/Navbar'; -import { isUserAuthenticated, extractUsernameFromToken, getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram } from '../../api/api'; +import { isUserAuthenticated, extractUsernameFromToken, getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, get2FAStatus, toggle2FA, getPublicSettings } from '../../api/api'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUser, faMapMarkerAlt, faPhone, faCommentDots, - faSave, faCheckCircle, faExclamationTriangle, faPaperPlane, faUnlink, faTimes, faLock, + faSave, faCheckCircle, faExclamationTriangle, faPaperPlane, faUnlink, faTimes, faLock, faShieldAlt, } from '@fortawesome/free-solid-svg-icons'; import './ProfilePage.css'; @@ -37,6 +37,11 @@ export default function ProfilePage() { const [tgEnabled, setTgEnabled] = useState(false); const [tgLoading, setTgLoading] = useState(false); + // 2FA + const [twoFAEnabled, setTwoFAEnabled] = useState(false); + const [twoFAAdminEnabled, setTwoFAAdminEnabled] = useState(false); + const [twoFALoading, setTwoFALoading] = useState(false); + // Modal confirmation infos par défaut const [showSaveModal, setShowSaveModal] = useState(false); @@ -50,6 +55,12 @@ export default function ProfilePage() { // Statut Telegram getTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); }); + // Statut 2FA + Promise.all([get2FAStatus(), getPublicSettings()]).then(([status, pub]) => { + setTwoFAEnabled(status.two_fa_enabled); + setTwoFAAdminEnabled(pub.two_fa_enabled); + }); + // Charger depuis backend getMyProfile().then((res) => { if (res.success && res.client) { @@ -99,9 +110,23 @@ export default function ProfilePage() { if (!window.confirm('Délier votre compte Telegram ? Vous ne recevrez plus de notifications.')) return; await unlinkTelegram(); setTgLinked(false); + setTwoFAEnabled(false); showSuccess('Compte Telegram délié'); }; + const handleToggle2FA = async () => { + const newVal = !twoFAEnabled; + setTwoFALoading(true); + const res = await toggle2FA(newVal); + setTwoFALoading(false); + if (res.success) { + setTwoFAEnabled(newVal); + showSuccess(newVal ? 'Double authentification activée' : 'Double authentification désactivée'); + } else { + showError(res.error || 'Erreur lors de la modification'); + } + }; + const saveContact = async () => { setSavingContact(true); const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() }); @@ -276,6 +301,34 @@ export default function ProfilePage() { )}
)} + + {/* Section 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */} + {twoFAAdminEnabled && tgLinked && ( +
+

+ + Double authentification (2FA) +

+

+ À chaque connexion, un code vous sera envoyé sur Telegram avant d'accéder à votre compte. +

+
+ + {twoFAEnabled && ( + + Activée + + )} +
+
+ )}
{showSaveModal && ( diff --git a/mobile/src/api/api.ts b/mobile/src/api/api.ts index d7ad039f..7f9de019 100644 --- a/mobile/src/api/api.ts +++ b/mobile/src/api/api.ts @@ -787,6 +787,7 @@ export interface PublicSettings { crypto_only: boolean; nowpayments_currencies: string[]; telegram_notifications_enabled: boolean; + two_fa_enabled: boolean; } export const getPublicSettings = async (): Promise => { @@ -801,6 +802,7 @@ export const getPublicSettings = async (): Promise => { crypto_only: false, nowpayments_currencies: [], telegram_notifications_enabled: false, + two_fa_enabled: false, }; try { const { data } = await apiClient.get(`${V1}/app-settings`); @@ -821,6 +823,7 @@ export const getPublicSettings = async (): Promise => { : [], telegram_notifications_enabled: data.telegram_notifications_enabled ?? false, + two_fa_enabled: data.two_fa_enabled ?? false, }; } catch { return defaults; @@ -889,6 +892,30 @@ export const unlinkTelegram = async (): Promise => { } }; +export const get2FAStatus = async (): Promise<{ + two_fa_enabled: boolean; + telegram_linked: boolean; + admin_2fa_enabled: boolean; +}> => { + try { + const { data } = await apiClient.get(`${V1}/two-fa/status`); + return data; + } catch { + return { two_fa_enabled: false, telegram_linked: false, admin_2fa_enabled: false }; + } +}; + +export const toggle2FA = async ( + enabled: boolean, +): Promise<{ success: boolean; error?: string }> => { + try { + const { data } = await apiClient.post(`${V1}/two-fa/toggle`, { enabled }); + return { success: data.success ?? true }; + } catch (e: any) { + return { success: false, error: e?.response?.data?.error || "Erreur" }; + } +}; + export const calculateOrderTotal = (order: any): number => { if (typeof order.total === "number" && order.total > 0) return order.total; if (typeof order.total_prix === "number" && order.total_prix > 0) diff --git a/mobile/src/screens/client/OrderHistoryScreen.tsx b/mobile/src/screens/client/OrderHistoryScreen.tsx index fc1db9fd..6f8614f5 100644 --- a/mobile/src/screens/client/OrderHistoryScreen.tsx +++ b/mobile/src/screens/client/OrderHistoryScreen.tsx @@ -45,7 +45,7 @@ export default function OrderHistoryScreen() { const [orders, setOrders] = useState([]); const [stats, setStats] = useState(null); const [penalties, setPenalties] = useState(null); - const [appSettings, setAppSettings] = useState({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: false, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, nowpayments_currencies: [], crypto_only: false, telegram_notifications_enabled: false }); + const [appSettings, setAppSettings] = useState({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: false, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, nowpayments_currencies: [], crypto_only: false, telegram_notifications_enabled: false, two_fa_enabled: false }); const [referralBalance, setReferralBalance] = useState(0); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); diff --git a/mobile/src/screens/client/ProfileScreen.tsx b/mobile/src/screens/client/ProfileScreen.tsx index 00d3184c..49c84997 100644 --- a/mobile/src/screens/client/ProfileScreen.tsx +++ b/mobile/src/screens/client/ProfileScreen.tsx @@ -6,6 +6,7 @@ import { ScrollView, StyleSheet, TouchableOpacity, + Switch, Alert, Modal, KeyboardAvoidingView, @@ -17,7 +18,7 @@ import { Feather } from "@expo/vector-icons"; import { useFocusEffect } from "@react-navigation/native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { Ionicons } from "@expo/vector-icons"; -import { getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, changePassword } from "../../api/api"; +import { getMyProfile, updateMyProfile, getTelegramStatus, generateTelegramLinkToken, unlinkTelegram, changePassword, get2FAStatus, toggle2FA, getPublicSettings } from "../../api/api"; import TextInput from "../../components/ui/TextInput"; import Button from "../../components/ui/Button"; import { useTheme } from "../../context/ThemeContext"; @@ -49,6 +50,11 @@ export default function ProfileScreen() { const [telegramEnabled, setTelegramEnabled] = useState(false); const [telegramLoading, setTelegramLoading] = useState(false); + // 2FA + const [twoFAEnabled, setTwoFAEnabled] = useState(false); + const [twoFAAdminEnabled, setTwoFAAdminEnabled] = useState(false); + const [twoFALoading, setTwoFALoading] = useState(false); + // Modal confirmation infos par défaut const [showSaveModal, setShowSaveModal] = useState(false); @@ -64,15 +70,19 @@ export default function ProfileScreen() { const loadData = useCallback(async () => { setLoadingProfile(true); - const [savedAddress, savedPhone, savedSignal, profileRes, tgStatus] = await Promise.all([ + const [savedAddress, savedPhone, savedSignal, profileRes, tgStatus, twoFAStatus, pubSettings] = await Promise.all([ AsyncStorage.getItem(STORAGE_ADDRESS), AsyncStorage.getItem(STORAGE_PHONE), AsyncStorage.getItem(STORAGE_SIGNAL), getMyProfile(), getTelegramStatus(), + get2FAStatus(), + getPublicSettings(), ]); setTelegramLinked(tgStatus.linked); setTelegramEnabled(tgStatus.enabled); + setTwoFAEnabled(twoFAStatus.two_fa_enabled); + setTwoFAAdminEnabled(pubSettings.two_fa_enabled); if (savedAddress !== null) setDefaultAddress(savedAddress); if (savedPhone !== null) setDefaultPhone(savedPhone); @@ -136,6 +146,7 @@ export default function ProfileScreen() { onPress: async () => { await unlinkTelegram(); setTelegramLinked(false); + setTwoFAEnabled(false); }, }, ], @@ -170,6 +181,17 @@ export default function ProfileScreen() { } }; + const handleToggle2FA = async (value: boolean) => { + setTwoFALoading(true); + const res = await toggle2FA(value); + setTwoFALoading(false); + if (res.success) { + setTwoFAEnabled(value); + } else { + Alert.alert("Erreur", res.error || "Impossible de modifier la 2FA"); + } + }; + const saveContact = async () => { setSavingContact(true); const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() }); @@ -327,6 +349,17 @@ export default function ProfileScreen() { }, pwdInputIcon: { marginRight: spacing.s }, pwdInput: { flex: 1, fontSize: fontSize.sm }, + twoFARow: { + flexDirection: "row" as const, + alignItems: "center" as const, + justifyContent: "space-between" as const, + paddingTop: spacing.xs, + }, + twoFALabel: { + color: colors.textPrimary, + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + }, }), [colors]); if (loadingProfile) { @@ -505,6 +538,42 @@ export default function ProfileScreen() {
)} + {/* Carte 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */} + {twoFAAdminEnabled && telegramLinked && ( + + + + Double authentification (2FA) + + + À chaque connexion, un code à 6 chiffres vous sera envoyé sur Telegram avant d'accéder à votre compte. + + + + + {twoFAEnabled ? "Activée" : "Désactivée"} + + {twoFAEnabled && ( + + + Protection activée + + )} + + {twoFALoading ? ( + + ) : ( + + )} + + + )} +