370 lines
20 KiB
React
370 lines
20 KiB
React
import { useState, useEffect, useRef } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '../context/AuthContext.jsx';
|
|
import { api } from '../api.js';
|
|
import AuthBgCol from '../components/AuthBgCol.jsx';
|
|
import PasswordInput from '../components/PasswordInput.jsx';
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
const DEVICE_KEY = 'cl_device_token';
|
|
|
|
function fmtCountdown(sec) {
|
|
const m = Math.floor(sec / 60).toString().padStart(2, '0');
|
|
const s = (sec % 60).toString().padStart(2, '0');
|
|
return `${m}:${s}`;
|
|
}
|
|
|
|
// ── Header logo (colonne droite) ──────────────────────────────────────────
|
|
function AppHeader({ appInfo }) {
|
|
return (
|
|
<div className="login-mobile-header" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, marginBottom: 32 }}>
|
|
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt={appInfo.appName} width={52} height={52} style={{ borderRadius: 12 }} />}
|
|
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.4px' }}>{appInfo.appName}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Composant principal ────────────────────────────────────────────────────
|
|
export default function Login() {
|
|
const { login, completeLogin } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true });
|
|
|
|
// Étape : 'form' | 'method' | 'code'
|
|
const [step, setStep] = useState('form');
|
|
const [sessionToken, setSessionToken] = useState(null);
|
|
const [method, setMethod] = useState(null); // 'totp' | 'email'
|
|
|
|
// Formulaire login
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [trustDev, setTrustDev] = useState(false);
|
|
const [err, setErr] = useState(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [unverified, setUnverified] = useState(false);
|
|
const [resendBusy, setResendBusy] = useState(false);
|
|
const [resendDone, setResendDone] = useState(false);
|
|
|
|
// Code 2FA
|
|
const [code, setCode] = useState('');
|
|
const [codeErr, setCodeErr] = useState(null);
|
|
const [codeBusy, setCodeBusy] = useState(false);
|
|
const [countdown, setCountdown] = useState(300); // 5 min pour email
|
|
const timerRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
fetch('/api/app-info').then(r => r.json()).then(d => setAppInfo(d)).catch(() => {});
|
|
}, []);
|
|
|
|
// Countdown pour OTP email
|
|
useEffect(() => {
|
|
if (step === 'code' && method === 'email') {
|
|
setCountdown(300);
|
|
timerRef.current = setInterval(() => {
|
|
setCountdown(c => {
|
|
if (c <= 1) { clearInterval(timerRef.current); return 0; }
|
|
return c - 1;
|
|
});
|
|
}, 1000);
|
|
}
|
|
return () => clearInterval(timerRef.current);
|
|
}, [step, method]);
|
|
|
|
// ── Soumission du formulaire ──────────────────────────────────────────────
|
|
const submit = async (e) => {
|
|
e.preventDefault();
|
|
setErr(null); setUnverified(false); setResendDone(false); setBusy(true);
|
|
try {
|
|
const deviceToken = localStorage.getItem(DEVICE_KEY) || undefined;
|
|
const result = await login(email, password, deviceToken);
|
|
if (result?.requires2FA) {
|
|
setSessionToken(result.sessionToken);
|
|
setStep('method');
|
|
} else {
|
|
navigate('/');
|
|
}
|
|
} catch (e) {
|
|
if (e.code === 'EMAIL_NOT_VERIFIED') {
|
|
setUnverified(true);
|
|
} else {
|
|
setErr(e.message || 'Identifiants incorrects.');
|
|
}
|
|
} finally { setBusy(false); }
|
|
};
|
|
|
|
const resend = async () => {
|
|
setResendBusy(true);
|
|
try {
|
|
await fetch('/api/auth/resend-verification', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
setResendDone(true);
|
|
} catch (_) {}
|
|
finally { setResendBusy(false); }
|
|
};
|
|
|
|
// ── Choix de méthode 2FA ──────────────────────────────────────────────────
|
|
const chooseMethod = async (m) => {
|
|
setMethod(m);
|
|
setCodeErr(null);
|
|
setCode('');
|
|
if (m === 'email') {
|
|
try {
|
|
await api.post('/auth/2fa/send-email-code', { sessionToken });
|
|
} catch (e) {
|
|
setCodeErr(e.message);
|
|
return;
|
|
}
|
|
}
|
|
setStep('code');
|
|
};
|
|
|
|
// ── Vérification du code ──────────────────────────────────────────────────
|
|
const verifyCode = async (e) => {
|
|
e.preventDefault();
|
|
setCodeErr(null); setCodeBusy(true);
|
|
try {
|
|
const result = await api.post('/auth/2fa/verify', {
|
|
sessionToken, code, method, trustDevice: trustDev,
|
|
});
|
|
if (result.deviceToken) {
|
|
localStorage.setItem(DEVICE_KEY, result.deviceToken);
|
|
}
|
|
completeLogin(result.token, result.user);
|
|
navigate('/');
|
|
} catch (e) {
|
|
setCodeErr(e.message || 'Code invalide.');
|
|
} finally { setCodeBusy(false); }
|
|
};
|
|
|
|
const resendEmailCode = async () => {
|
|
setCodeErr(null);
|
|
try {
|
|
await api.post('/auth/2fa/send-email-code', { sessionToken });
|
|
setCountdown(300);
|
|
timerRef.current = setInterval(() => {
|
|
setCountdown(c => { if (c <= 1) { clearInterval(timerRef.current); return 0; } return c - 1; });
|
|
}, 1000);
|
|
} catch (e) { setCodeErr(e.message); }
|
|
};
|
|
|
|
// ── Rendu ─────────────────────────────────────────────────────────────────
|
|
const BtnPrimary = ({ children, disabled, onClick, type = 'submit' }) => (
|
|
<button type={type} disabled={disabled} onClick={onClick} style={{
|
|
width: '100%', padding: '11px 0',
|
|
background: disabled ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
|
|
color: '#fff', border: 'none', borderRadius: 8,
|
|
fontSize: 15, fontWeight: 600,
|
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
transition: 'background 0.15s',
|
|
}}>{children}</button>
|
|
);
|
|
|
|
const ErrBox = ({ msg }) => msg ? (
|
|
<div style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, background: 'var(--danger-bg, #fef2f2)', color: 'var(--danger, #dc2626)', border: '1px solid var(--danger-light, #fca5a5)' }}>
|
|
{msg}
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
|
<AuthBgCol appInfo={appInfo} />
|
|
|
|
<div style={{
|
|
flex: '1 1 50%', display: 'flex', flexDirection: 'column',
|
|
alignItems: 'center', justifyContent: 'center',
|
|
padding: '48px 24px', background: 'var(--background, #fff)', minWidth: 0,
|
|
}}>
|
|
<AppHeader appInfo={appInfo} />
|
|
|
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
|
|
|
{/* ══ ÉTAPE 1 : Formulaire email/mot de passe ══════════════════ */}
|
|
{step === 'form' && (
|
|
<>
|
|
<div style={{ marginBottom: 28 }}>
|
|
<h1 style={{ margin: '0 0 6px', fontSize: 26, fontWeight: 700, letterSpacing: '-0.5px', color: 'var(--text)' }}>Bienvenue</h1>
|
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>Connectez-vous à votre compte</p>
|
|
</div>
|
|
|
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<ErrBox msg={err} />
|
|
|
|
{unverified && (
|
|
<div style={{ padding: '12px 14px', borderRadius: 8, fontSize: 13, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid var(--warning-border, #fcd34d)', lineHeight: 1.5 }}>
|
|
<strong>Email non vérifié.</strong> Vérifiez votre boîte mail et cliquez sur le lien reçu.
|
|
<div style={{ marginTop: 8 }}>
|
|
{resendDone
|
|
? <span style={{ color: 'var(--success, #16a34a)', fontWeight: 500 }}>✓ Email renvoyé !</span>
|
|
: <button type="button" onClick={() => resend()} disabled={resendBusy} style={{ background: 'none', border: 'none', padding: 0, color: 'var(--primary, #1e40af)', fontWeight: 600, fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
|
|
{resendBusy ? 'Envoi…' : "Renvoyer l'email de vérification"}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Adresse email</label>
|
|
<input className="form-input" type="email" required autoComplete="email" placeholder="vous@exemple.com"
|
|
value={email} onChange={e => setEmail(e.target.value)} style={{ width: '100%' }} />
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Mot de passe</label>
|
|
<Link to="/forgot-password" style={{ fontSize: 13, color: 'var(--text-muted)', textDecoration: 'underline' }}>Mot de passe oublié ?</Link>
|
|
</div>
|
|
<PasswordInput className="form-input" required autoComplete="current-password" placeholder="••••••••"
|
|
value={password} onChange={e => setPassword(e.target.value)} style={{ width: '100%' }} />
|
|
</div>
|
|
|
|
<BtnPrimary disabled={busy}>{busy ? 'Connexion…' : 'Se connecter'}</BtnPrimary>
|
|
</form>
|
|
|
|
{appInfo.allowRegistration !== false && (
|
|
<p style={{ marginTop: 24, textAlign: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
|
Pas encore de compte ?{' '}
|
|
<Link to="/register" style={{ color: 'var(--text)', fontWeight: 500, textDecoration: 'underline' }}>Créer un compte</Link>
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ══ ÉTAPE 2 : Choix de méthode 2FA ══════════════════════════ */}
|
|
{step === 'method' && (
|
|
<>
|
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
|
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--primary-bg, #eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
<rect x="5" y="11" width="14" height="10" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/>
|
|
<path d="M8 11V7a4 4 0 018 0v4" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round"/>
|
|
<circle cx="12" cy="16" r="1.5" fill="var(--primary,#1e40af)"/>
|
|
</svg>
|
|
</div>
|
|
<h1 style={{ margin: '0 0 6px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>Vérification en deux étapes</h1>
|
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>Choisissez votre méthode de vérification</p>
|
|
</div>
|
|
|
|
<ErrBox msg={codeErr} />
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: codeErr ? 16 : 0 }}>
|
|
<button onClick={() => chooseMethod('totp')} style={{
|
|
display: 'flex', alignItems: 'center', gap: 14,
|
|
padding: '14px 16px', borderRadius: 10,
|
|
border: '1.5px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
|
|
cursor: 'pointer', textAlign: 'left', transition: 'border-color 0.15s',
|
|
}} onMouseEnter={e => e.currentTarget.style.borderColor='var(--primary,#1e40af)'}
|
|
onMouseLeave={e => e.currentTarget.style.borderColor='var(--border)'}>
|
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
<rect x="7" y="2" width="10" height="20" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/>
|
|
<line x1="12" y1="18" x2="12" y2="18" stroke="var(--primary,#1e40af)" strokeWidth="2.5" strokeLinecap="round"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Application d'authentification</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Google Authenticator, Authy, etc.</div>
|
|
</div>
|
|
</button>
|
|
|
|
<button onClick={() => chooseMethod('email')} style={{
|
|
display: 'flex', alignItems: 'center', gap: 14,
|
|
padding: '14px 16px', borderRadius: 10,
|
|
border: '1.5px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
|
|
cursor: 'pointer', textAlign: 'left', transition: 'border-color 0.15s',
|
|
}} onMouseEnter={e => e.currentTarget.style.borderColor='var(--primary,#1e40af)'}
|
|
onMouseLeave={e => e.currentTarget.style.borderColor='var(--border)'}>
|
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Code par email</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Recevoir un code à {email}</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<button type="button" onClick={() => { setStep('form'); setErr(null); }} style={{ marginTop: 24, width: '100%', background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
|
|
← Retour à la connexion
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* ══ ÉTAPE 3 : Saisie du code ═════════════════════════════════ */}
|
|
{step === 'code' && (
|
|
<>
|
|
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
|
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
|
|
{method === 'totp'
|
|
? <svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect x="7" y="2" width="10" height="20" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/><line x1="12" y1="18" x2="12" y2="18" stroke="var(--primary,#1e40af)" strokeWidth="2.5" strokeLinecap="round"/></svg>
|
|
: <svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
}
|
|
</div>
|
|
<h1 style={{ margin: '0 0 6px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
|
|
{method === 'totp' ? 'Code de l\'application' : 'Code par email'}
|
|
</h1>
|
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 13, lineHeight: 1.5 }}>
|
|
{method === 'totp'
|
|
? 'Entrez le code à 6 chiffres affiché dans votre application d\'authentification.'
|
|
: <>Code envoyé à <strong>{email}</strong>.</>
|
|
}
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={verifyCode} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<ErrBox msg={codeErr} />
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Code de vérification</label>
|
|
{method === 'email' && (
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: countdown > 0 ? 'var(--text-muted)' : 'var(--danger,#dc2626)' }}>
|
|
{countdown > 0 ? fmtCountdown(countdown) : 'Expiré'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<input
|
|
className="form-input"
|
|
type="text" inputMode="numeric" pattern="[0-9]{6}"
|
|
maxLength={6} autoComplete="one-time-code"
|
|
placeholder="000000"
|
|
value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
style={{ width: '100%', fontSize: 24, letterSpacing: 8, textAlign: 'center', fontWeight: 700 }}
|
|
/>
|
|
</div>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--text-muted)', cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={trustDev} onChange={e => setTrustDev(e.target.checked)} style={{ width: 'auto' }} />
|
|
Faire confiance à cet appareil pendant 30 jours
|
|
</label>
|
|
|
|
<BtnPrimary disabled={codeBusy || code.length !== 6}>
|
|
{codeBusy ? 'Vérification…' : 'Vérifier'}
|
|
</BtnPrimary>
|
|
|
|
{method === 'email' && countdown === 0 && (
|
|
<button type="button" onClick={resendEmailCode} style={{ background: 'none', border: 'none', color: 'var(--primary,#1e40af)', fontWeight: 600, fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
|
|
Renvoyer le code
|
|
</button>
|
|
)}
|
|
</form>
|
|
|
|
<button type="button" onClick={() => { setStep('method'); setCode(''); setCodeErr(null); clearInterval(timerRef.current); }} style={{ marginTop: 20, width: '100%', background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
|
|
← Changer de méthode
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|