feat: add 2FA and change title
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface LoginResponse {
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
user?: UserResponse;
|
||||
requires_2fa?: boolean;
|
||||
session_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user