Modification des pages d'authentification
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -2,6 +2,9 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './context/AuthContext.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Register from './pages/Register.jsx';
|
||||
import ForgotPassword from './pages/ForgotPassword.jsx';
|
||||
import ResetPassword from './pages/ResetPassword.jsx';
|
||||
import VerifyEmail from './pages/VerifyEmail.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import Dashboard from './pages/Dashboard.jsx';
|
||||
import DepotsRetraits from './pages/DepotsRetraits.jsx';
|
||||
@@ -36,8 +39,11 @@ function AdminOnly({ children }) {
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route element={<Protected><Layout /></Protected>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="plateformes" element={<Plateformes />} />
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ async function handle(res) {
|
||||
if (!res.ok) {
|
||||
const msg = (body && body.error) || res.statusText || 'Request failed';
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
err.status = res.status;
|
||||
err.code = body && body.code;
|
||||
err.details = body && body.details;
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -17,16 +17,32 @@ export function AuthProvider({ children }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const r = await api.post('/auth/login', { email, password });
|
||||
const login = async (email, password, deviceToken) => {
|
||||
const body = { email, password, ...(deviceToken ? { deviceToken } : {}) };
|
||||
const r = await api.post('/auth/login', body);
|
||||
if (r.requires2FA) {
|
||||
// Authentification incomplète — retourne les infos pour la page Login
|
||||
return { requires2FA: true, sessionToken: r.sessionToken, email: r.email };
|
||||
}
|
||||
localStorage.setItem('cl_token', r.token);
|
||||
setToken(r.token);
|
||||
setUser(r.user);
|
||||
return r.user;
|
||||
};
|
||||
|
||||
// Appelé par Login après validation du code 2FA
|
||||
const completeLogin = (token, userData) => {
|
||||
localStorage.setItem('cl_token', token);
|
||||
setToken(token);
|
||||
setUser(userData);
|
||||
};
|
||||
|
||||
const register = async (email, password, displayName) => {
|
||||
const r = await api.post('/auth/register', { email, password, displayName });
|
||||
if (r.requiresVerification) {
|
||||
// Compte créé mais email non vérifié — pas de connexion automatique
|
||||
return { requiresVerification: true, email: r.email };
|
||||
}
|
||||
localStorage.setItem('cl_token', r.token);
|
||||
setToken(r.token);
|
||||
setUser(r.user);
|
||||
@@ -54,7 +70,7 @@ export function AuthProvider({ children }) {
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ token, user, loading, login, register, logout, updateUser, isAdmin }}>
|
||||
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, isAdmin }}>
|
||||
{children}
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [status, setStatus] = useState(null); // null | 'sent' | 'error'
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/app-info')
|
||||
.then(r => r.json())
|
||||
.then(d => setAppInfo(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setErrMsg(''); setBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setErrMsg(data.error || 'Une erreur est survenue.'); setStatus('error'); }
|
||||
else setStatus('sent');
|
||||
} catch {
|
||||
setErrMsg('Impossible de joindre le serveur.'); setStatus('error');
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||
|
||||
{/* ── Colonne gauche — image ───────────────────────────── */}
|
||||
<div className="login-bg-col" style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'none',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: '#0d0d0d',
|
||||
}}>
|
||||
<img
|
||||
src="/login-bg.jpg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
onError={e => { e.target.style.display = 'none'; }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
width: '100%', height: '100%',
|
||||
objectFit: 'cover', opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 40, left: 40,
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
{appInfo.iconUrl && (
|
||||
<img src={appInfo.iconUrl} alt="" width={36} height={36}
|
||||
style={{ borderRadius: 8, flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
|
||||
{appInfo.appName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Colonne droite — formulaire ──────────────────────── */}
|
||||
<div style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 24px',
|
||||
background: 'var(--background, #fff)',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
|
||||
{/* Logo + nom */}
|
||||
<div 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>
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||
|
||||
{status === 'sent' ? (
|
||||
/* ── Confirmation envoi ── */
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: '50%',
|
||||
background: 'var(--success-bg, #f0fdf4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
|
||||
strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style={{
|
||||
margin: '0 0 10px', fontSize: 22, fontWeight: 700,
|
||||
letterSpacing: '-0.4px', color: 'var(--text)',
|
||||
}}>Email envoyé</h1>
|
||||
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
|
||||
Si un compte correspond à <strong>{email}</strong>, vous recevrez
|
||||
un lien de réinitialisation valable <strong>1 heure</strong>.
|
||||
</p>
|
||||
<Link to="/login" style={{
|
||||
display: 'block', width: '100%', padding: '11px 0',
|
||||
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||
textDecoration: 'none', textAlign: 'center',
|
||||
}}>
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Formulaire ── */
|
||||
<>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h1 style={{
|
||||
margin: '0 0 6px', fontSize: 26, fontWeight: 700,
|
||||
letterSpacing: '-0.5px', color: 'var(--text)',
|
||||
}}>
|
||||
Mot de passe oublié ?
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
Saisissez votre email pour recevoir un lien de réinitialisation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{status === 'error' && (
|
||||
<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)',
|
||||
}}>
|
||||
{errMsg}
|
||||
</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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
style={{
|
||||
marginTop: 4, width: '100%',
|
||||
padding: '11px 0',
|
||||
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
|
||||
color: '#fff',
|
||||
border: 'none', borderRadius: 8,
|
||||
fontSize: 15, fontWeight: 600,
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{busy ? 'Envoi…' : 'Envoyer les instructions'}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<p style={{
|
||||
marginTop: 24, textAlign: 'center',
|
||||
fontSize: 13, color: 'var(--text-muted)',
|
||||
}}>
|
||||
Vous vous souvenez ?{' '}
|
||||
<Link to="/login" style={{
|
||||
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
|
||||
}}>
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media (min-width: 768px) {
|
||||
.login-bg-col { display: block !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+378
-35
@@ -1,44 +1,387 @@
|
||||
import { useState } from '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';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
const DEVICE_KEY = 'cl_device_token';
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
function fmtCountdown(sec) {
|
||||
const m = Math.floor(sec / 60).toString().padStart(2, '0');
|
||||
const s = (sec % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
// ── Colonne image gauche (partagée) ───────────────────────────────────────
|
||||
function BgCol({ appInfo }) {
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<h2 style={{ marginTop: 0 }}>Connexion</h2>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<label>Email</label>
|
||||
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} />
|
||||
<div style={{ height: 10 }} />
|
||||
<label>Mot de passe</label>
|
||||
<input type="password" required value={password} onChange={e => setPassword(e.target.value)} />
|
||||
<div style={{ height: 16 }} />
|
||||
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
|
||||
{busy ? '…' : 'Se connecter'}
|
||||
</button>
|
||||
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
Pas encore de compte ? <Link to="/register">Créer un compte</Link>
|
||||
</p>
|
||||
</form>
|
||||
<div className="login-bg-col" style={{
|
||||
flex: '1 1 50%', display: 'none',
|
||||
position: 'relative', overflow: 'hidden', background: '#0d0d0d',
|
||||
}}>
|
||||
<img src="/login-bg.jpg" alt="" aria-hidden="true"
|
||||
onError={e => { e.target.style.display = 'none'; }}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', bottom: 40, left: 40, color: '#fff', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt="" width={36} height={36} style={{ borderRadius: 8, flexShrink: 0 }} />}
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>{appInfo.appName}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 });
|
||||
|
||||
// É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' }}>
|
||||
<BgCol 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>
|
||||
<input className="form-input" type="password" 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>
|
||||
|
||||
<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>
|
||||
|
||||
<style>{`
|
||||
@media (min-width: 768px) { .login-bg-col { display: block !important; } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,84 @@ function ProfileSelect({ label, options, value, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bloc renvoi email de vérification ───────────────────────── */
|
||||
function EmailResendBlock({ email }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const resend = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fetch('/api/auth/resend-verification', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
setDone(true);
|
||||
} catch (_) {}
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (done) return <p style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 6 }}>✓ Email de vérification renvoyé.</p>;
|
||||
return (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
|
||||
Email non confirmé.{' '}
|
||||
<button type="button" onClick={() => resend()} disabled={busy}
|
||||
style={{ background: 'none', border: 'none', padding: 0, color: 'var(--primary)', fontWeight: 600, fontSize: 12, cursor: 'pointer', textDecoration: 'underline' }}>
|
||||
{busy ? 'Envoi…' : 'Renvoyer le lien'}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Changement d'email ───────────────────────────────────────── */
|
||||
function EmailChangeForm({ onDone }) {
|
||||
const { updateUser } = useAuth();
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(null); setMsg(null); setBusy(true);
|
||||
try {
|
||||
const r = await updateUser({ email: newEmail, currentPassword: pwd });
|
||||
if (r?.requiresVerification) {
|
||||
setMsg(`Un email de vérification a été envoyé à ${newEmail}. Validez-le pour activer cette adresse.`);
|
||||
} else {
|
||||
setMsg('Email mis à jour.'); setTimeout(onDone, 1500);
|
||||
}
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Après le changement, un email de vérification sera envoyé à la nouvelle adresse.
|
||||
</p>
|
||||
{err && <div className="error">{err}</div>}
|
||||
{msg && <div className="success-msg">{msg}</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label className="profile-label">Nouvelle adresse email</label>
|
||||
<input className="profile-input" type="email" required value={newEmail} onChange={e => setNewEmail(e.target.value)} placeholder="nouvelle@email.com" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label className="profile-label">Mot de passe actuel (confirmation)</label>
|
||||
<input className="profile-input" type="password" required value={pwd} onChange={e => setPwd(e.target.value)} placeholder="••••••••" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy} style={{ fontSize: 13 }}>
|
||||
{busy ? 'Enregistrement…' : 'Confirmer le changement'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => onDone()} style={{ fontSize: 13 }}>Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Mon profil + Préférences ────────────────────────────────── */
|
||||
function AccountForm() {
|
||||
const { user, updateUser } = useAuth();
|
||||
@@ -92,9 +170,10 @@ function AccountForm() {
|
||||
const initial = parseName(user?.display_name);
|
||||
const [prenom, setPrenom] = useState(initial.prenom);
|
||||
const [nom, setNom] = useState(initial.nom);
|
||||
const [infoMsg, setInfoMsg] = useState(null);
|
||||
const [infoErr, setInfoErr] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [infoMsg, setInfoMsg] = useState(null);
|
||||
const [infoErr, setInfoErr] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [changingEmail, setChangingEmail] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
setInfoErr(null); setInfoMsg(null); setLoading(true);
|
||||
@@ -138,16 +217,23 @@ function AccountForm() {
|
||||
|
||||
<div className="profile-field profile-field-full">
|
||||
<span className="profile-label">Mon email</span>
|
||||
<div className="profile-email-row">
|
||||
<div className="profile-email-row" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span className="profile-email-value">{user?.email}</span>
|
||||
{user?.email_verified
|
||||
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}>✓ Vérifié</span>
|
||||
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid #fcd34d' }}>Non vérifié</span>
|
||||
}
|
||||
</div>
|
||||
{!user?.email_verified && <EmailResendBlock email={user?.email} />}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<button className="profile-manage-btn" type="button" disabled>
|
||||
Gérer mon email
|
||||
<button className="profile-manage-btn" type="button" onClick={() => setChangingEmail(v => !v)}>
|
||||
{changingEmail ? 'Annuler' : 'Changer mon email'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{changingEmail && <EmailChangeForm onDone={() => setChangingEmail(false)} />}
|
||||
</section>
|
||||
|
||||
{/* ── Préférences ─────────────────────────────────────── */}
|
||||
@@ -164,6 +250,163 @@ function AccountForm() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ── 2FA — Configuration double authentification ───────────────── */
|
||||
function TwoFASection({ user }) {
|
||||
const [status, setStatus] = useState('idle'); // idle | setup | confirming | disabling
|
||||
const [setupData, setSetupData] = useState(null); // { secret, qrCode, email }
|
||||
const [code, setCode] = useState('');
|
||||
const [disablePwd, setDisablePwd] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [enabled, setEnabled] = useState(!!user?.totp_enabled);
|
||||
|
||||
const startSetup = async () => {
|
||||
setErr(null); setMsg(null); setBusy(true);
|
||||
try {
|
||||
const data = await api.get('/auth/2fa/setup');
|
||||
setSetupData(data);
|
||||
setStatus('setup');
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const confirmSetup = async (e) => {
|
||||
e.preventDefault(); setErr(null); setBusy(true);
|
||||
try {
|
||||
await api.post('/auth/2fa/confirm-setup', { code });
|
||||
setEnabled(true);
|
||||
setStatus('idle');
|
||||
setSetupData(null);
|
||||
setCode('');
|
||||
setMsg('Le 2FA est maintenant activé sur votre compte.');
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const disable = async (e) => {
|
||||
e.preventDefault(); setErr(null); setBusy(true);
|
||||
try {
|
||||
await api.post('/auth/2fa/disable', { password: disablePwd });
|
||||
setEnabled(false);
|
||||
setStatus('idle');
|
||||
setDisablePwd('');
|
||||
setMsg('Le 2FA a été désactivé.');
|
||||
localStorage.removeItem('cl_device_token');
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 }}>
|
||||
<h3 style={{ margin: 0 }}>Double authentification (2FA)</h3>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, padding: '3px 10px', borderRadius: 12,
|
||||
background: enabled ? 'var(--success-bg,#f0fdf4)' : 'var(--surface-2,#f4f4f5)',
|
||||
color: enabled ? 'var(--success,#16a34a)' : 'var(--text-muted)',
|
||||
border: `1px solid ${enabled ? '#bbf7d0' : 'var(--border)'}`,
|
||||
}}>
|
||||
{enabled ? '✓ Activé' : 'Désactivé'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Protégez votre compte avec une vérification supplémentaire à chaque connexion.
|
||||
</p>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
{msg && <div className="success-msg" style={{ marginBottom: 12 }}>{msg}</div>}
|
||||
|
||||
{/* ── État normal ── */}
|
||||
{status === 'idle' && !enabled && (
|
||||
<button className="primary" onClick={() => startSetup()} disabled={busy}>
|
||||
{busy ? 'Chargement…' : 'Activer le 2FA'}
|
||||
</button>
|
||||
)}
|
||||
{status === 'idle' && enabled && (
|
||||
<button className="btn btn-outline" style={{ color: 'var(--danger,#dc2626)', borderColor: 'var(--danger,#dc2626)' }}
|
||||
onClick={() => { setStatus('disabling'); setErr(null); setMsg(null); }}>
|
||||
Désactiver le 2FA
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Setup : QR code + vérification ── */}
|
||||
{status === 'setup' && setupData && (
|
||||
<div>
|
||||
<p style={{ margin: '0 0 12px', fontSize: 13, lineHeight: 1.6 }}>
|
||||
Scannez ce QR code avec votre application d'authentification (<strong>Google Authenticator</strong>, <strong>Authy</strong>, etc.), puis entrez le code à 6 chiffres pour confirmer.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start', flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
<div style={{ background: '#fff', padding: 12, borderRadius: 8, border: '1px solid var(--border)', display: 'inline-block' }}>
|
||||
<img src={setupData.qrCode} alt="QR code 2FA" width={160} height={160} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<p style={{ margin: '0 0 6px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Vous ne pouvez pas scanner ? Entrez manuellement cette clé dans votre application :
|
||||
</p>
|
||||
<code style={{
|
||||
display: 'block', background: 'var(--surface-2)', padding: '8px 10px',
|
||||
borderRadius: 6, fontSize: 13, letterSpacing: 1, wordBreak: 'break-all',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
{setupData.secret}
|
||||
</code>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
Émetteur : <strong>{setupData.issuer}</strong> · Compte : <strong>{setupData.email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={confirmSetup} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', maxWidth: 320 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500 }}>Code de vérification</label>
|
||||
<input
|
||||
type="text" inputMode="numeric" maxLength={6} pattern="[0-9]{6}"
|
||||
placeholder="000000" value={code}
|
||||
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
autoComplete="one-time-code"
|
||||
style={{ fontSize: 20, letterSpacing: 6, textAlign: 'center', fontWeight: 700 }}
|
||||
/>
|
||||
</div>
|
||||
<button className="primary" type="submit" disabled={busy || code.length !== 6} style={{ flexShrink: 0 }}>
|
||||
{busy ? '…' : 'Confirmer'}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onClick={() => { setStatus('idle'); setSetupData(null); setCode(''); setErr(null); }}
|
||||
style={{ marginTop: 12, background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Désactivation : mot de passe ── */}
|
||||
{status === 'disabling' && (
|
||||
<form onSubmit={disable} style={{ maxWidth: 360 }}>
|
||||
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Confirmez votre mot de passe pour désactiver le 2FA.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500 }}>Mot de passe actuel</label>
|
||||
<input type="password" required value={disablePwd}
|
||||
onChange={e => setDisablePwd(e.target.value)}
|
||||
autoComplete="current-password" placeholder="••••••••" />
|
||||
</div>
|
||||
<button className="btn" style={{ background: 'var(--danger,#dc2626)', color: '#fff', flexShrink: 0 }}
|
||||
type="submit" disabled={busy || !disablePwd}>
|
||||
{busy ? '…' : 'Désactiver'}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => { setStatus('idle'); setErr(null); setDisablePwd(''); }}
|
||||
style={{ marginTop: 10, background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>
|
||||
Annuler
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sécurité — Mot de passe ─────────────────────────────────── */
|
||||
function SecurityForm() {
|
||||
const { updateUser } = useAuth();
|
||||
@@ -249,6 +492,7 @@ function SecurityForm() {
|
||||
export default function MonCompte() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const section = new URLSearchParams(search).get('section') || 'profil';
|
||||
const setSection = (s) => navigate(`/compte?section=${s}`, { replace: true });
|
||||
@@ -278,7 +522,7 @@ export default function MonCompte() {
|
||||
{/* ── Contenu ─────────────────────────────────────── */}
|
||||
<div className="account-content">
|
||||
{section === 'profil' && <AccountForm />}
|
||||
{section === 'securite' && <SecurityForm />}
|
||||
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /></>}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
+240
-23
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
|
||||
@@ -8,40 +8,257 @@ export default function Register() {
|
||||
const [form, setForm] = useState({ email: '', password: '', displayName: '' });
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/app-info')
|
||||
.then(r => r.json())
|
||||
.then(d => setAppInfo(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
const [verifyEmail, setVerifyEmail] = useState(null); // email à vérifier
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
await register(form.email, form.password, form.displayName || undefined);
|
||||
navigate('/');
|
||||
const result = await register(form.email, form.password, form.displayName || undefined);
|
||||
if (result?.requiresVerification) {
|
||||
setVerifyEmail(result.email);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<h2 style={{ marginTop: 0 }}>Créer un compte</h2>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<label>Nom d'affichage</label>
|
||||
<input value={form.displayName} onChange={set('displayName')} placeholder="Olivier" />
|
||||
<div style={{ height: 10 }} />
|
||||
<label>Email</label>
|
||||
<input type="email" required value={form.email} onChange={set('email')} />
|
||||
<div style={{ height: 10 }} />
|
||||
<label>Mot de passe (8 car. min.)</label>
|
||||
<input type="password" required minLength={8} value={form.password} onChange={set('password')} />
|
||||
<div style={{ height: 16 }} />
|
||||
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
|
||||
{busy ? '…' : 'Créer le compte'}
|
||||
</button>
|
||||
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
Déjà inscrit ? <Link to="/login">Se connecter</Link>
|
||||
</p>
|
||||
</form>
|
||||
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||
|
||||
{/* ── Colonne gauche — image ───────────────────────────── */}
|
||||
<div className="login-bg-col" style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'none',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: '#0d0d0d',
|
||||
}}>
|
||||
<img
|
||||
src="/login-bg.jpg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
onError={e => { e.target.style.display = 'none'; }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
width: '100%', height: '100%',
|
||||
objectFit: 'cover', opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 40, left: 40,
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
{appInfo.iconUrl && (
|
||||
<img src={appInfo.iconUrl} alt="" width={36} height={36}
|
||||
style={{ borderRadius: 8, flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
|
||||
{appInfo.appName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Colonne droite — formulaire ──────────────────────── */}
|
||||
<div style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 24px',
|
||||
background: 'var(--background, #fff)',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
|
||||
{/* Logo + nom */}
|
||||
<div 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>
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||
|
||||
{/* ── Écran vérification email ── */}
|
||||
{verifyEmail ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%',
|
||||
background: 'var(--primary-bg, #eff6ff)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
}}>
|
||||
<svg width="26" height="26" 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 10px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
|
||||
Vérifiez votre email
|
||||
</h1>
|
||||
<p style={{ margin: '0 0 6px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
|
||||
Un email de vérification a été envoyé à
|
||||
</p>
|
||||
<p style={{ margin: '0 0 24px', fontWeight: 600, color: 'var(--text)', fontSize: 14 }}>
|
||||
{verifyEmail}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 13, lineHeight: 1.6 }}>
|
||||
Cliquez sur le lien dans l'email pour activer votre compte. Vérifiez aussi vos spams.
|
||||
</p>
|
||||
<Link to="/login" style={{
|
||||
display: 'block', width: '100%', padding: '11px 0',
|
||||
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||
textDecoration: 'none', textAlign: 'center',
|
||||
}}>
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
{/* En-tête */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h1 style={{
|
||||
margin: '0 0 6px',
|
||||
fontSize: 26, fontWeight: 700,
|
||||
letterSpacing: '-0.5px',
|
||||
color: 'var(--text)',
|
||||
}}>
|
||||
Créer un compte
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
Rejoignez {appInfo.appName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Formulaire */}
|
||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{err && (
|
||||
<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)',
|
||||
}}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
Nom d'affichage
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="Olivier"
|
||||
value={form.displayName}
|
||||
onChange={set('displayName')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</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={form.email}
|
||||
onChange={set('email')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
Mot de passe <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(8 car. min.)</span>
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="password" required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={set('password')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
style={{
|
||||
marginTop: 4, width: '100%',
|
||||
padding: '11px 0',
|
||||
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
|
||||
color: '#fff',
|
||||
border: 'none', borderRadius: 8,
|
||||
fontSize: 15, fontWeight: 600,
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{busy ? 'Création…' : 'Créer le compte'}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<p style={{
|
||||
marginTop: 24, textAlign: 'center',
|
||||
fontSize: 13, color: 'var(--text-muted)',
|
||||
}}>
|
||||
Déjà inscrit ?{' '}
|
||||
<Link to="/login" style={{
|
||||
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
|
||||
}}>
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
</>)} {/* fin verifyEmail ternaire */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media (min-width: 768px) {
|
||||
.login-bg-col { display: block !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = params.get('token') || '';
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [password2, setPassword2] = useState('');
|
||||
const [status, setStatus] = useState(null); // null | 'done' | 'error'
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/app-info')
|
||||
.then(r => r.json())
|
||||
.then(d => setAppInfo(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (password !== password2) { setErrMsg('Les mots de passe ne correspondent pas.'); setStatus('error'); return; }
|
||||
setErrMsg(''); setBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setErrMsg(data.error || 'Une erreur est survenue.'); setStatus('error'); }
|
||||
else { setStatus('done'); setTimeout(() => navigate('/login'), 3000); }
|
||||
} catch {
|
||||
setErrMsg('Impossible de joindre le serveur.'); setStatus('error');
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||
|
||||
{/* ── Colonne gauche — image ───────────────────────────── */}
|
||||
<div className="login-bg-col" style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'none',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: '#0d0d0d',
|
||||
}}>
|
||||
<img
|
||||
src="/login-bg.jpg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
onError={e => { e.target.style.display = 'none'; }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
width: '100%', height: '100%',
|
||||
objectFit: 'cover', opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 40, left: 40,
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
{appInfo.iconUrl && (
|
||||
<img src={appInfo.iconUrl} alt="" width={36} height={36}
|
||||
style={{ borderRadius: 8, flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
|
||||
{appInfo.appName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Colonne droite — formulaire ──────────────────────── */}
|
||||
<div style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 24px',
|
||||
background: 'var(--background, #fff)',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
|
||||
{/* Logo + nom */}
|
||||
<div 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>
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||
|
||||
{!token ? (
|
||||
/* ── Lien invalide ── */
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--danger)', marginBottom: 20 }}>
|
||||
Lien de réinitialisation invalide ou manquant.
|
||||
</p>
|
||||
<Link to="/forgot-password" style={{
|
||||
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
|
||||
}}>
|
||||
Faire une nouvelle demande
|
||||
</Link>
|
||||
</div>
|
||||
) : status === 'done' ? (
|
||||
/* ── Succès ── */
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: '50%',
|
||||
background: 'var(--success-bg, #f0fdf4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
|
||||
strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style={{
|
||||
margin: '0 0 10px', fontSize: 22, fontWeight: 700,
|
||||
letterSpacing: '-0.4px', color: 'var(--text)',
|
||||
}}>Mot de passe mis à jour</h1>
|
||||
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
Redirection vers la connexion dans quelques secondes…
|
||||
</p>
|
||||
<Link to="/login" style={{
|
||||
display: 'block', width: '100%', padding: '11px 0',
|
||||
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||
textDecoration: 'none', textAlign: 'center',
|
||||
}}>
|
||||
Se connecter
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Formulaire ── */
|
||||
<>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<h1 style={{
|
||||
margin: '0 0 6px', fontSize: 26, fontWeight: 700,
|
||||
letterSpacing: '-0.5px', color: 'var(--text)',
|
||||
}}>
|
||||
Nouveau mot de passe
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
Choisissez un mot de passe d'au moins 8 caractères.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{status === 'error' && (
|
||||
<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)',
|
||||
}}>
|
||||
{errMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
Nouveau mot de passe
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="password" required minLength={8}
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); setStatus(null); }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="password" required minLength={8}
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
value={password2}
|
||||
onChange={e => { setPassword2(e.target.value); setStatus(null); }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
style={{
|
||||
marginTop: 4, width: '100%',
|
||||
padding: '11px 0',
|
||||
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
|
||||
color: '#fff',
|
||||
border: 'none', borderRadius: 8,
|
||||
fontSize: 15, fontWeight: 600,
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{busy ? 'Enregistrement…' : 'Réinitialiser le mot de passe'}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<p style={{
|
||||
marginTop: 24, textAlign: 'center',
|
||||
fontSize: 13, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Link to="/login" style={{
|
||||
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
|
||||
}}>
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media (min-width: 768px) {
|
||||
.login-bg-col { display: block !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const [params] = useSearchParams();
|
||||
const token = params.get('token') || '';
|
||||
const [status, setStatus] = useState('loading'); // 'loading' | 'ok' | 'error'
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/app-info').then(r => r.json()).then(d => setAppInfo(d)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setStatus('error'); setErrMsg('Lien de vérification invalide ou manquant.'); return; }
|
||||
fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
|
||||
.then(async r => {
|
||||
const data = await r.json();
|
||||
if (!r.ok) { setErrMsg(data.error || 'Lien invalide.'); setStatus('error'); }
|
||||
else setStatus('ok');
|
||||
})
|
||||
.catch(() => { setErrMsg('Impossible de joindre le serveur.'); setStatus('error'); });
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100dvh' }}>
|
||||
|
||||
<div className="login-bg-col" style={{
|
||||
flex: '1 1 50%', display: 'none', position: 'relative',
|
||||
overflow: 'hidden', background: '#0d0d0d',
|
||||
}}>
|
||||
<img src="/login-bg.jpg" alt="" aria-hidden="true"
|
||||
onError={e => { e.target.style.display = 'none'; }}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', bottom: 40, left: 40, color: '#fff', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt="" width={36} height={36} style={{ borderRadius: 8 }} />}
|
||||
<span style={{ fontSize: 18, fontWeight: 600 }}>{appInfo.appName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: '1 1 50%', display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
padding: '48px 24px', background: 'var(--background, #fff)', minWidth: 0,
|
||||
}}>
|
||||
<div 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>
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 360, textAlign: 'center' }}>
|
||||
|
||||
{status === 'loading' && (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Vérification en cours…</p>
|
||||
)}
|
||||
|
||||
{status === 'ok' && (
|
||||
<>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%',
|
||||
background: 'var(--success-bg, #f0fdf4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
}}>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
|
||||
strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style={{ margin: '0 0 10px', fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>
|
||||
Email vérifié !
|
||||
</h1>
|
||||
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
|
||||
Votre adresse email a bien été confirmée. Vous pouvez maintenant vous connecter.
|
||||
</p>
|
||||
<Link to="/login" style={{
|
||||
display: 'block', width: '100%', padding: '11px 0',
|
||||
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
Se connecter
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%',
|
||||
background: 'var(--danger-bg, #fef2f2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
}}>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
stroke="var(--danger, #dc2626)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 style={{ margin: '0 0 10px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
|
||||
Lien invalide
|
||||
</h1>
|
||||
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
|
||||
{errMsg}
|
||||
</p>
|
||||
<Link to="/login" style={{
|
||||
display: 'block', width: '100%', padding: '11px 0',
|
||||
background: 'var(--primary, #1e40af)', color: '#fff',
|
||||
borderRadius: 8, fontSize: 15, fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
Retour à la connexion
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media (min-width: 768px) { .login-bg-col { display: block !important; } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ const DEFAULT_STATE = {
|
||||
username: '',
|
||||
password: '',
|
||||
allowUnauth: false,
|
||||
appName: 'Crowdlending',
|
||||
appName: 'Crowdlending Tracker',
|
||||
appUrl: '',
|
||||
};
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function SmtpSection() {
|
||||
username: data.username || '',
|
||||
password: '',
|
||||
allowUnauth: !!data.allowUnauth,
|
||||
appName: data.appName || 'Crowdlending',
|
||||
appName: data.appName || 'Crowdlending Tracker',
|
||||
appUrl: data.appUrl || '',
|
||||
});
|
||||
setHasPassword(!!data.hasPassword);
|
||||
|
||||
@@ -36,6 +36,21 @@ export default function UsersSection({ currentUserId }) {
|
||||
});
|
||||
};
|
||||
|
||||
const verifyEmail = (u) => {
|
||||
setConfirmAction({
|
||||
title: 'Vérifier l\'email manuellement',
|
||||
message: `Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
|
||||
confirmLabel: 'Confirmer',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.patch(`/admin/users/${u.id}/verify-email`, {});
|
||||
load();
|
||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = (u) => {
|
||||
setConfirmAction({
|
||||
title: 'Supprimer l\'utilisateur',
|
||||
@@ -66,6 +81,7 @@ export default function UsersSection({ currentUserId }) {
|
||||
<th style={{ width: 36 }}>ID</th>
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Email vérifié</th>
|
||||
<th>Rôle</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
@@ -77,10 +93,16 @@ export default function UsersSection({ currentUserId }) {
|
||||
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
|
||||
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}>—</em>}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>
|
||||
{u.email_verified
|
||||
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}>✓ Vérifié</span>
|
||||
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid #fcd34d' }}>En attente</span>
|
||||
}
|
||||
</td>
|
||||
<td><Badge role={u.role} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => toggleRole(u)}
|
||||
@@ -89,6 +111,11 @@ export default function UsersSection({ currentUserId }) {
|
||||
>
|
||||
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
|
||||
</button>
|
||||
{!u.email_verified && (
|
||||
<button className="btn btn-sm btn-outline" onClick={() => verifyEmail(u)}>
|
||||
Vérifier email
|
||||
</button>
|
||||
)}
|
||||
{u.id !== currentUserId && (
|
||||
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
|
||||
Supprimer
|
||||
|
||||
Reference in New Issue
Block a user