4 Commits
Author SHA1 Message Date
Xor290 97381b8b68 chore: add pre-prod branch in CI backend 2026-05-09 15:25:12 +02:00
Xor290 8ebb6d2370 chore: add backend 2026-05-09 15:22:24 +02:00
Xor290 eb8ba01159 chore: delete ci 2026-05-09 15:21:10 +02:00
Xor290 1672dc1e20 feat: add 2FA and change title 2026-05-09 15:17:19 +02:00
21 changed files with 624 additions and 69 deletions
+2 -2
View File
@@ -2,11 +2,11 @@ name: Backend - Build & Lint
on: on:
push: push:
branches: [main] branches: [main, pre-prod]
paths: paths:
- "backend/**/**" - "backend/**/**"
pull_request: pull_request:
branches: [main] branches: [main, pre-prod]
paths: paths:
- "backend/**/**" - "backend/**/**"
+8 -1
View File
@@ -374,10 +374,12 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
MustChangePassword bool `gorm:"column:must_change_password"` MustChangePassword bool `gorm:"column:must_change_password"`
PointsExtraJSON []byte `gorm:"column:points_extra"` PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"` CreatedAt time.Time `gorm:"column:created_at"`
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende, 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 FROM clients WHERE username = ?`, username).Scan(&row).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err) 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, Amende: row.Amende,
MustChangePassword: row.MustChangePassword, MustChangePassword: row.MustChangePassword,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
TwoFAEnabled: row.TwoFAEnabled,
} }
client.PointsExtra = map[string]int{} client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 { if len(row.PointsExtraJSON) > 0 {
@@ -406,6 +409,10 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
return client, nil 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) { func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) {
amende, err := d.GetClientAmende(username) amende, err := d.GetClientAmende(username)
if err != nil { if err != nil {
+8
View File
@@ -66,6 +66,7 @@ func DefaultSettings() models.AppSettings {
}, },
}, },
}, },
ShopName: "Milieu-Nantais",
DeliveryMode: models.DeliveryModeConfig{ DeliveryMode: models.DeliveryModeConfig{
Mode: "single", Mode: "single",
CategoryRoutes: []models.CategoryRoute{}, CategoryRoutes: []models.CategoryRoute{},
@@ -81,6 +82,7 @@ func DefaultSettings() models.AppSettings {
"44860", "44220", "44118", "44710", "44690", "44119", "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 { if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
settings.DeliveryMode = mode settings.DeliveryMode = mode
} }
case "telegram_2fa_enabled":
settings.Telegram2FAEnabled = row.Value == "true"
case "shop_name":
settings.ShopName = row.Value
} }
} }
return settings, nil return settings, nil
@@ -226,7 +232,9 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
{"telegram_bot_token", s.TelegramBotToken}, {"telegram_bot_token", s.TelegramBotToken},
{"telegram_bot_username", s.TelegramBotUsername}, {"telegram_bot_username", s.TelegramBotUsername},
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)}, {"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
{"delivery_mode", string(deliveryModeJSON)}, {"delivery_mode", string(deliveryModeJSON)},
{"shop_name", s.ShopName},
} }
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
+34
View File
@@ -15,6 +15,7 @@ func (d *Database) MigrateAddTelegramColumns() {
migrations := []string{ migrations := []string{
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`, `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 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 { for _, q := range migrations {
if err := d.GDB.Exec(q).Error; err != nil { 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") 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
}
+154 -4
View File
@@ -1,8 +1,11 @@
package handlers package handlers
import ( import (
"crypto/rand"
"fmt"
"gestion/db" "gestion/db"
"gestion/models" "gestion/models"
"gestion/services"
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
@@ -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 // LoginClient authentifie un client
func LoginClient(c *gin.Context) { func LoginClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req models.LoginRequest var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err) log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
@@ -251,8 +262,6 @@ func LoginClient(c *gin.Context) {
return return
} }
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByUsername(req.Username) client, err := database.GetClientByUsername(req.Username)
if err != nil || client == nil { if err != nil || client == nil {
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username) log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
@@ -266,6 +275,30 @@ func LoginClient(c *gin.Context) {
return 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 : <b>%s</b>\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) token, err := generateClientToken(client)
if err != nil { if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err) log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
@@ -280,7 +313,6 @@ func LoginClient(c *gin.Context) {
return return
} }
// Créer la session Redis
sessionID := uuid.New().String() sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil { if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err) log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
@@ -303,7 +335,125 @@ func LoginClient(c *gin.Context) {
}) })
} }
// ChangePassword permet à un client de changer son mot de passe 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})
}
func ChangePassword(c *gin.Context) { func ChangePassword(c *gin.Context) {
var req struct { var req struct {
CurrentPassword string `json:"current_password" binding:"required"` CurrentPassword string `json:"current_password" binding:"required"`
+2
View File
@@ -44,6 +44,8 @@ func GetPublicSettings(c *gin.Context) {
"crypto_only": settings.CryptoOnly, "crypto_only": settings.CryptoOnly,
"nowpayments_currencies": settings.NowPaymentsCurrencies, "nowpayments_currencies": settings.NowPaymentsCurrencies,
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled, "telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
"shop_name": settings.ShopName,
"two_fa_enabled": settings.Telegram2FAEnabled,
}) })
} }
+7
View File
@@ -242,13 +242,20 @@ func UnlinkClientTelegram(c *gin.Context) {
return return
} }
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
if err := database.DeleteClientTelegramChatID(username); err != nil { if err := database.DeleteClientTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err) log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return 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) log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true}) c.JSON(http.StatusOK, gin.H{"success": true})
} }
-4
View File
@@ -18,10 +18,6 @@ type AdminClaims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// ============================================
// STRUCTURES REQUÊTE / RÉPONSE
// ============================================
type LoginRequest struct { type LoginRequest struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
+1
View File
@@ -21,6 +21,7 @@ type Client struct {
Parrain string `gorm:"column:parrain" json:"parrain"` Parrain string `gorm:"column:parrain" json:"parrain"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_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" } func (Client) TableName() string { return "clients" }
+2
View File
@@ -79,4 +79,6 @@ type AppSettings struct {
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs 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
} }
+5
View File
@@ -34,6 +34,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
{ {
authGroupV1.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginClient) authGroupV1.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginClient)
authGroupV1.POST("/logout", handlers.LogoutClient) authGroupV1.POST("/logout", handlers.LogoutClient)
authGroupV1.POST("/2fa/verify", middleware.LoginRateLimitMiddleware, handlers.Verify2FAClient)
} }
// Route change-password (auth client requise) // 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.GET("/profile", handlers.GetMyProfile) // ✅ Récupérer mon profil
cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier 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 // 🎁 PARRAINAGE CLIENT
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance) cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo) cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo)
+2
View File
@@ -958,7 +958,9 @@ export interface AppSettings {
telegram_bot_token: string; telegram_bot_token: string;
telegram_bot_username: string; telegram_bot_username: string;
telegram_notifications_enabled: boolean; telegram_notifications_enabled: boolean;
telegram_2fa_enabled: boolean;
delivery_mode: DeliveryModeConfig; delivery_mode: DeliveryModeConfig;
shop_name: string;
} }
export const getSettings = async (): Promise<{ export const getSettings = async (): Promise<{
@@ -677,7 +677,9 @@ export default function SettingsScreen() {
telegram_bot_token: "", telegram_bot_token: "",
telegram_bot_username: "", telegram_bot_username: "",
telegram_notifications_enabled: false, telegram_notifications_enabled: false,
telegram_2fa_enabled: false,
delivery_mode: { mode: "single" as const, category_routes: [] }, delivery_mode: { mode: "single" as const, category_routes: [] },
shop_name: "Milieu-Nantais",
}); });
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [showIpnSecret, setShowIpnSecret] = useState(false); const [showIpnSecret, setShowIpnSecret] = useState(false);
@@ -939,6 +941,27 @@ export default function SettingsScreen() {
return ( return (
<View style={s.container}> <View style={s.container}>
<ScrollView contentContainerStyle={s.content}> <ScrollView contentContainerStyle={s.content}>
{/* Personnalisation */}
<View style={s.section}>
<Text style={s.sectionTitle}>Personnalisation</Text>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Nom du shop</Text>
<Text style={s.rowDesc}>Affiché dans la sidebar du site client</Text>
</View>
</View>
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l }}>
<TextInput
style={s.input}
value={settings.shop_name}
onChangeText={(v) => setSettings((p) => ({ ...p, shop_name: v }))}
placeholder="Ex: Milieu-Nantais"
placeholderTextColor={colors.textMuted}
autoCorrect={false}
/>
</View>
</View>
{/* Amendes */} {/* Amendes */}
<View style={s.section}> <View style={s.section}>
<Text style={s.sectionTitle}>Amendes</Text> <Text style={s.sectionTitle}>Amendes</Text>
@@ -1412,6 +1435,18 @@ export default function SettingsScreen() {
thumbColor="#fff" thumbColor="#fff"
/> />
</View> </View>
<View style={s.row}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Authentification 2FA</Text>
<Text style={s.rowDesc}>Permettre aux clients d'activer la double authentification via Telegram lors de la connexion</Text>
</View>
<Switch
value={settings.telegram_2fa_enabled}
onValueChange={(v) => setSettings((prev) => ({ ...prev, telegram_2fa_enabled: v }))}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}> <View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}>
<Text style={s.rowDesc}> <Text style={s.rowDesc}>
Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte. Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte.
+74
View File
@@ -41,6 +41,8 @@ export interface AuthResponse {
access_token?: string; // ✅ CRITICAL! access_token?: string; // ✅ CRITICAL!
token_type?: string; token_type?: string;
expires_in?: number; expires_in?: number;
requires_2fa?: boolean;
session_token?: string;
user?: { user?: {
id: number; id: number;
username: string; username: string;
@@ -210,6 +212,15 @@ export const loginUser = async (
const data = await safeJson(response); const data = await safeJson(response);
console.log("📋 [LOGIN] Réponse:", data); 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 // ✅ Vérifier access_token
if (!data.access_token) { if (!data.access_token) {
console.error("❌ [LOGIN] Pas de access_token"); console.error("❌ [LOGIN] Pas de access_token");
@@ -1866,6 +1877,8 @@ export interface PublicSettings {
crypto_payment_enabled: boolean; crypto_payment_enabled: boolean;
crypto_only: boolean; crypto_only: boolean;
nowpayments_currencies: string[]; nowpayments_currencies: string[];
shop_name: string;
two_fa_enabled: boolean;
} }
export const getPublicSettings = async (): Promise<PublicSettings> => { export const getPublicSettings = async (): Promise<PublicSettings> => {
@@ -1880,6 +1893,8 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
crypto_payment_enabled: false, crypto_payment_enabled: false,
crypto_only: false, crypto_only: false,
nowpayments_currencies: [], nowpayments_currencies: [],
shop_name: "Milieu-Nantais",
two_fa_enabled: false,
}; };
try { try {
const response = await fetch(`${API_URL}/app-settings`); const response = await fetch(`${API_URL}/app-settings`);
@@ -1901,12 +1916,71 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
nowpayments_currencies: Array.isArray(data.nowpayments_currencies) nowpayments_currencies: Array.isArray(data.nowpayments_currencies)
? data.nowpayments_currencies ? data.nowpayments_currencies
: [], : [],
shop_name: data.shop_name || "Milieu-Nantais",
two_fa_enabled: data.two_fa_enabled ?? false,
}; };
} catch { } catch {
return defaults; return defaults;
} }
}; };
export const verify2FA = async (
sessionToken: string,
code: string,
): Promise<AuthResponse> => {
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 { export interface CryptoPaymentStatus {
command_id: number; command_id: number;
client_order_number?: number; client_order_number?: number;
+2
View File
@@ -58,6 +58,8 @@ export interface LoginResponse {
token_type?: string; token_type?: string;
expires_in?: number; expires_in?: number;
user?: UserResponse; user?: UserResponse;
requires_2fa?: boolean;
session_token?: string;
} }
/** /**
+6 -2
View File
@@ -39,6 +39,7 @@ function Navbar() {
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [showNotifPanel, setShowNotifPanel] = useState(false); const [showNotifPanel, setShowNotifPanel] = useState(false);
const [referralEnabled, setReferralEnabled] = useState(true); const [referralEnabled, setReferralEnabled] = useState(true);
const [shopName, setShopName] = useState("Milieu-Nantais");
const seenKeysRef = useRef<Set<string>>(new Set()); const seenKeysRef = useRef<Set<string>>(new Set());
const isFirstLoadRef = useRef(true); const isFirstLoadRef = useRef(true);
const notifPanelRef = useRef<HTMLDivElement>(null); const notifPanelRef = useRef<HTMLDivElement>(null);
@@ -74,7 +75,10 @@ function Navbar() {
}, [fetchNotifications]); }, [fetchNotifications]);
useEffect(() => { useEffect(() => {
getPublicSettings().then((s) => setReferralEnabled(s.referral_enabled)); getPublicSettings().then((s) => {
setReferralEnabled(s.referral_enabled);
if (s.shop_name) setShopName(s.shop_name);
});
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -235,7 +239,7 @@ function Navbar() {
<FontAwesomeIcon icon={faShoppingCart} /> <FontAwesomeIcon icon={faShoppingCart} />
</div> </div>
<div> <div>
<p className="sidebar-brand-name">Milieu-Nantais</p> <p className="sidebar-brand-name">{shopName}</p>
<p className="sidebar-brand-sub">Mon espace</p> <p className="sidebar-brand-sub">Mon espace</p>
</div> </div>
</div> </div>
+95 -18
View File
@@ -1,7 +1,7 @@
import { useState } from "react"; 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 "./Login.css";
import { loginUser, syncUsernameFromJWT } from "../../api/api"; import { loginUser, verify2FA, syncUsernameFromJWT } from "../../api/api";
import type { LoginRequest } from "../../api/api_types"; import type { LoginRequest } from "../../api/api_types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -21,6 +21,10 @@ const LoginClient = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>(""); const [apiError, setApiError] = useState<string>("");
const [twoFAStep, setTwoFAStep] = useState(false);
const [sessionToken, setSessionToken] = useState("");
const [twoFACode, setTwoFACode] = useState("");
/** /**
* ✅ Valider le formulaire * ✅ Valider le formulaire
*/ */
@@ -79,30 +83,19 @@ const LoginClient = () => {
hasToken: !!result.access_token, hasToken: !!result.access_token,
}); });
if (result.success && result.access_token) { if (result.success && result.requires_2fa) {
console.log("✅ [LOGIN] Connexion réussie!"); setSessionToken(result.session_token || "");
setTwoFAStep(true);
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginUser() } else if (result.success && result.access_token) {
// Pas besoin de le faire ici
console.log("✅ [LOGIN] Token et username synchronisés");
// ✅ Vérifier la synchronisation
const syncedUsername = syncUsernameFromJWT(); const syncedUsername = syncUsernameFromJWT();
console.log("✅ [LOGIN] Username synchronisé:", syncedUsername); console.log("✅ [LOGIN] Username synchronisé:", syncedUsername);
// ✅ Redirection
if (result.user?.must_change_password) { if (result.user?.must_change_password) {
console.log("✅ [LOGIN] Première connexion - changement de mot de passe requis");
navigate("/user/change-password"); navigate("/user/change-password");
} else { } else {
console.log("✅ [LOGIN] Redirection vers /user/accueil");
navigate("/user/accueil"); navigate("/user/accueil");
} }
} else { } else {
// ❌ Erreur API const errorMessage = result.message || "Identifiants incorrects";
const errorMessage =
result.message || "Identifiants incorrects";
console.error("❌ [LOGIN] Erreur API:", errorMessage);
setApiError(errorMessage); setApiError(errorMessage);
setErrors({ username: errorMessage }); setErrors({ username: errorMessage });
} }
@@ -117,6 +110,30 @@ const LoginClient = () => {
} }
}; };
const handle2FASubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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 * ✅ Gérer les changements d'input
*/ */
@@ -141,6 +158,66 @@ const LoginClient = () => {
} }
}; };
if (twoFAStep) {
return (
<div className="login-container">
<div className="login-content">
<div className="login-header">
<div className="login-logo">
<Shield className="w-8 h-8 text-white" />
</div>
<h1 className="login-title">Vérification 2FA</h1>
<p className="login-subtitle">
Entrez le code envoyé sur votre Telegram
</p>
</div>
<div className="login-card">
<form className="login-form" onSubmit={handle2FASubmit}>
{apiError && (
<div className="error-banner"> {apiError}</div>
)}
<div className="form-group">
<label htmlFor="twoFACode" className="form-label">
Code de vérification
</label>
<div className="input-wrapper">
<input
type="text"
id="twoFACode"
value={twoFACode}
onChange={(e) => 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" }}
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || twoFACode.length < 6}
className="submit-button"
style={{ opacity: isLoading ? 0.6 : 1, cursor: isLoading ? "not-allowed" : "pointer" }}
>
{isLoading ? "Vérification..." : "Confirmer"}
</button>
<button
type="button"
onClick={() => { setTwoFAStep(false); setTwoFACode(""); setApiError(""); }}
className="submit-button"
style={{ marginTop: "0.5rem", background: "transparent", border: "1px solid #555", color: "#aaa" }}
>
Retour
</button>
</form>
</div>
</div>
</div>
);
}
return ( return (
<div className="login-container"> <div className="login-container">
<div className="login-content"> <div className="login-content">
+55 -2
View File
@@ -1,11 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Navbar from '../../components/Navbar'; 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faUser, faMapMarkerAlt, faPhone, faCommentDots, 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'; } from '@fortawesome/free-solid-svg-icons';
import './ProfilePage.css'; import './ProfilePage.css';
@@ -37,6 +37,11 @@ export default function ProfilePage() {
const [tgEnabled, setTgEnabled] = useState(false); const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = 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 // Modal confirmation infos par défaut
const [showSaveModal, setShowSaveModal] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false);
@@ -50,6 +55,12 @@ export default function ProfilePage() {
// Statut Telegram // Statut Telegram
getTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); }); 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 // Charger depuis backend
getMyProfile().then((res) => { getMyProfile().then((res) => {
if (res.success && res.client) { 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; if (!window.confirm('Délier votre compte Telegram ? Vous ne recevrez plus de notifications.')) return;
await unlinkTelegram(); await unlinkTelegram();
setTgLinked(false); setTgLinked(false);
setTwoFAEnabled(false);
showSuccess('Compte Telegram délié'); 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 () => { const saveContact = async () => {
setSavingContact(true); setSavingContact(true);
const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() }); const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() });
@@ -276,6 +301,34 @@ export default function ProfilePage() {
)} )}
</div> </div>
)} )}
{/* Section 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */}
{twoFAAdminEnabled && tgLinked && (
<div className="profile-card">
<h2 className="profile-card-title">
<FontAwesomeIcon icon={faShieldAlt} className="profile-card-icon" style={{ color: '#6366f1' }} />
Double authentification (2FA)
</h2>
<p className="profile-hint">
À chaque connexion, un code vous sera envoyé sur Telegram avant d'accéder à votre compte.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.8rem' }}>
<button
className={`profile-btn ${twoFAEnabled ? 'profile-btn--danger' : 'profile-btn--telegram'}`}
onClick={handleToggle2FA}
disabled={twoFALoading}
>
<FontAwesomeIcon icon={faShieldAlt} />
{twoFALoading ? ' ...' : twoFAEnabled ? ' Désactiver la 2FA' : ' Activer la 2FA'}
</button>
{twoFAEnabled && (
<span style={{ color: '#10b981', fontSize: '0.9rem' }}>
<FontAwesomeIcon icon={faCheckCircle} /> Activée
</span>
)}
</div>
</div>
)}
</div> </div>
{showSaveModal && ( {showSaveModal && (
+27
View File
@@ -787,6 +787,7 @@ export interface PublicSettings {
crypto_only: boolean; crypto_only: boolean;
nowpayments_currencies: string[]; nowpayments_currencies: string[];
telegram_notifications_enabled: boolean; telegram_notifications_enabled: boolean;
two_fa_enabled: boolean;
} }
export const getPublicSettings = async (): Promise<PublicSettings> => { export const getPublicSettings = async (): Promise<PublicSettings> => {
@@ -801,6 +802,7 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
crypto_only: false, crypto_only: false,
nowpayments_currencies: [], nowpayments_currencies: [],
telegram_notifications_enabled: false, telegram_notifications_enabled: false,
two_fa_enabled: false,
}; };
try { try {
const { data } = await apiClient.get(`${V1}/app-settings`); const { data } = await apiClient.get(`${V1}/app-settings`);
@@ -821,6 +823,7 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
: [], : [],
telegram_notifications_enabled: telegram_notifications_enabled:
data.telegram_notifications_enabled ?? false, data.telegram_notifications_enabled ?? false,
two_fa_enabled: data.two_fa_enabled ?? false,
}; };
} catch { } catch {
return defaults; return defaults;
@@ -889,6 +892,30 @@ export const unlinkTelegram = async (): Promise<void> => {
} }
}; };
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 => { export const calculateOrderTotal = (order: any): number => {
if (typeof order.total === "number" && order.total > 0) return order.total; if (typeof order.total === "number" && order.total > 0) return order.total;
if (typeof order.total_prix === "number" && order.total_prix > 0) if (typeof order.total_prix === "number" && order.total_prix > 0)
@@ -45,7 +45,7 @@ export default function OrderHistoryScreen() {
const [orders, setOrders] = useState<CompletedOrder[]>([]); const [orders, setOrders] = useState<CompletedOrder[]>([]);
const [stats, setStats] = useState<ClientStats | null>(null); const [stats, setStats] = useState<ClientStats | null>(null);
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null); const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
const [appSettings, setAppSettings] = useState<PublicSettings>({ 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<PublicSettings>({ 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 [referralBalance, setReferralBalance] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
+71 -2
View File
@@ -6,6 +6,7 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Switch,
Alert, Alert,
Modal, Modal,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -17,7 +18,7 @@ import { Feather } from "@expo/vector-icons";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { Ionicons } from "@expo/vector-icons"; 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 TextInput from "../../components/ui/TextInput";
import Button from "../../components/ui/Button"; import Button from "../../components/ui/Button";
import { useTheme } from "../../context/ThemeContext"; import { useTheme } from "../../context/ThemeContext";
@@ -49,6 +50,11 @@ export default function ProfileScreen() {
const [telegramEnabled, setTelegramEnabled] = useState(false); const [telegramEnabled, setTelegramEnabled] = useState(false);
const [telegramLoading, setTelegramLoading] = 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 // Modal confirmation infos par défaut
const [showSaveModal, setShowSaveModal] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false);
@@ -64,15 +70,19 @@ export default function ProfileScreen() {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoadingProfile(true); 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_ADDRESS),
AsyncStorage.getItem(STORAGE_PHONE), AsyncStorage.getItem(STORAGE_PHONE),
AsyncStorage.getItem(STORAGE_SIGNAL), AsyncStorage.getItem(STORAGE_SIGNAL),
getMyProfile(), getMyProfile(),
getTelegramStatus(), getTelegramStatus(),
get2FAStatus(),
getPublicSettings(),
]); ]);
setTelegramLinked(tgStatus.linked); setTelegramLinked(tgStatus.linked);
setTelegramEnabled(tgStatus.enabled); setTelegramEnabled(tgStatus.enabled);
setTwoFAEnabled(twoFAStatus.two_fa_enabled);
setTwoFAAdminEnabled(pubSettings.two_fa_enabled);
if (savedAddress !== null) setDefaultAddress(savedAddress); if (savedAddress !== null) setDefaultAddress(savedAddress);
if (savedPhone !== null) setDefaultPhone(savedPhone); if (savedPhone !== null) setDefaultPhone(savedPhone);
@@ -136,6 +146,7 @@ export default function ProfileScreen() {
onPress: async () => { onPress: async () => {
await unlinkTelegram(); await unlinkTelegram();
setTelegramLinked(false); 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 () => { const saveContact = async () => {
setSavingContact(true); setSavingContact(true);
const res = await updateMyProfile({ nom: nom.trim(), prenom: prenom.trim(), telephone: telephone.trim() }); 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 }, pwdInputIcon: { marginRight: spacing.s },
pwdInput: { flex: 1, fontSize: fontSize.sm }, 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]); }), [colors]);
if (loadingProfile) { if (loadingProfile) {
@@ -505,6 +538,42 @@ export default function ProfileScreen() {
</View> </View>
)} )}
{/* Carte 2FA — visible uniquement si l'admin l'a activé ET Telegram est lié */}
{twoFAAdminEnabled && telegramLinked && (
<View style={styles.card}>
<View style={styles.cardTitle}>
<Ionicons name="shield-checkmark-outline" size={18} color="#6366f1" />
<Text style={styles.cardTitleText}>Double authentification (2FA)</Text>
</View>
<Text style={styles.hint}>
À chaque connexion, un code à 6 chiffres vous sera envoyé sur Telegram avant d'accéder à votre compte.
</Text>
<View style={styles.twoFARow}>
<View style={{ flex: 1 }}>
<Text style={styles.twoFALabel}>
{twoFAEnabled ? "Activée" : "Désactivée"}
</Text>
{twoFAEnabled && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: 2 }}>
<Ionicons name="checkmark-circle" size={13} color="#10b981" />
<Text style={{ color: "#10b981", fontSize: fontSize.xs }}>Protection activée</Text>
</View>
)}
</View>
{twoFALoading ? (
<ActivityIndicator size="small" color="#6366f1" />
) : (
<Switch
value={twoFAEnabled}
onValueChange={handleToggle2FA}
trackColor={{ false: colors.border, true: "#6366f155" }}
thumbColor={twoFAEnabled ? "#6366f1" : colors.textMuted}
/>
)}
</View>
</View>
)}
</ScrollView> </ScrollView>
<Modal <Modal