feat: add 2FA and change title

This commit is contained in:
2026-05-09 15:17:19 +02:00
parent 771f514af3
commit 1672dc1e20
19 changed files with 622 additions and 62 deletions
+74
View File
@@ -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<PublicSettings> => {
@@ -1880,6 +1893,8 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
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<PublicSettings> => {
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<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 {
command_id: number;
client_order_number?: number;
+2
View File
@@ -58,6 +58,8 @@ export interface LoginResponse {
token_type?: string;
expires_in?: number;
user?: UserResponse;
requires_2fa?: boolean;
session_token?: string;
}
/**
+6 -2
View File
@@ -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<Set<string>>(new Set());
const isFirstLoadRef = useRef(true);
const notifPanelRef = useRef<HTMLDivElement>(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() {
<FontAwesomeIcon icon={faShoppingCart} />
</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>
</div>
</div>
+95 -18
View File
@@ -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<string>("");
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<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
*/
@@ -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 (
<div className="login-container">
<div className="login-content">
+55 -2
View File
@@ -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() {
)}
</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>
{showSaveModal && (