386 lines
15 KiB
React
386 lines
15 KiB
React
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { api } from '../../api.js';
|
||
import { useAuth } from '../../context/AuthContext.jsx';
|
||
|
||
/* ── Icône œil (show/hide password) ───────────────────────────────────── */
|
||
function IconEye({ open }) {
|
||
return open
|
||
? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>;
|
||
}
|
||
|
||
/* ── Toggle switch ─────────────────────────────────────────────────────── */
|
||
function Toggle({ checked, onChange, id }) {
|
||
return (
|
||
<label htmlFor={id} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||
<input
|
||
id={id}
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={e => onChange(e.target.checked)}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<span style={{
|
||
display: 'inline-block',
|
||
width: 44,
|
||
height: 24,
|
||
borderRadius: 12,
|
||
background: checked ? 'var(--primary)' : 'var(--border)',
|
||
position: 'relative',
|
||
transition: 'background 0.2s',
|
||
flexShrink: 0,
|
||
}}>
|
||
<span style={{
|
||
position: 'absolute',
|
||
top: 3,
|
||
left: checked ? 23 : 3,
|
||
width: 18,
|
||
height: 18,
|
||
borderRadius: '50%',
|
||
background: '#fff',
|
||
transition: 'left 0.2s',
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||
}} />
|
||
</span>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
/* ── Ligne de paramètre ────────────────────────────────────────────────── */
|
||
function SettingRow({ label, description, children }) {
|
||
return (
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: '12px 24px',
|
||
alignItems: 'center',
|
||
padding: '18px 0',
|
||
borderBottom: '1px solid var(--border)',
|
||
}}>
|
||
<div>
|
||
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: description ? 4 : 0 }}>{label}</div>
|
||
{description && <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>{description}</div>}
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const DEFAULT_STATE = {
|
||
enabled: false,
|
||
host: '',
|
||
port: 587,
|
||
secure: false,
|
||
email: '',
|
||
username: '',
|
||
password: '',
|
||
allowUnauth: false,
|
||
};
|
||
|
||
export default function SmtpSection() {
|
||
const { user } = useAuth();
|
||
const [form, setForm] = useState(DEFAULT_STATE);
|
||
const [hasPassword, setHasPassword] = useState(false);
|
||
const [showPassword, setShowPassword] = useState(false);
|
||
const passwordTyped = useRef(false); // true uniquement si l'utilisateur a tapé manuellement
|
||
const [testEmail, setTestEmail] = useState(user?.email || '');
|
||
const [result, setResult] = useState(null); // { ok, msg }
|
||
const [testResult, setTestResult] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [testing, setTesting] = useState(false);
|
||
const [loadingEnv, setLoadingEnv] = useState(false);
|
||
const [saveStatus, setSaveStatus] = useState(null); // 'saving' | 'saved' | 'error'
|
||
const debounceRef = useRef(null);
|
||
const isFirstLoad = useRef(true);
|
||
const hasPasswordRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
api.get('/admin/smtp').then(data => {
|
||
setForm({
|
||
enabled: !!data.enabled,
|
||
host: data.host || '',
|
||
port: data.port || 587,
|
||
secure: !!data.secure,
|
||
email: data.email || '',
|
||
username: data.username || '',
|
||
password: '',
|
||
allowUnauth: !!data.allowUnauth,
|
||
});
|
||
setHasPassword(!!data.hasPassword);
|
||
hasPasswordRef.current = !!data.hasPassword;
|
||
});
|
||
}, []);
|
||
|
||
const doSave = useCallback(async (currentForm) => {
|
||
setSaveStatus('saving');
|
||
try {
|
||
const payload = { ...currentForm };
|
||
// N'envoyer le mot de passe que si l'utilisateur l'a tapé manuellement
|
||
if (!payload.password || !passwordTyped.current) delete payload.password;
|
||
await api.put('/admin/smtp', payload);
|
||
if (payload.password) {
|
||
setHasPassword(true);
|
||
hasPasswordRef.current = true;
|
||
}
|
||
setSaveStatus('saved');
|
||
setTimeout(() => setSaveStatus(null), 2500);
|
||
} catch (e) {
|
||
setSaveStatus('error');
|
||
setResult({ ok: false, msg: e.message || 'Erreur lors de la sauvegarde.' });
|
||
}
|
||
}, []);
|
||
|
||
const set = (key, value) => {
|
||
setForm(f => {
|
||
const next = { ...f, [key]: value };
|
||
// Ne pas déclencher l'auto-save au chargement initial
|
||
if (isFirstLoad.current) { isFirstLoad.current = false; return next; }
|
||
clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(() => doSave(next), 1500);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Nettoyage du timer au démontage
|
||
useEffect(() => () => clearTimeout(debounceRef.current), []);
|
||
|
||
const handleTest = async () => {
|
||
if (!testEmail) return;
|
||
setTesting(true);
|
||
setTestResult(null);
|
||
try {
|
||
const data = await api.post('/admin/smtp/test', { to: testEmail });
|
||
setTestResult({ ok: data.ok, msg: data.msg });
|
||
} catch (e) {
|
||
setTestResult({ ok: false, msg: e.message || 'Erreur lors du test.' });
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
};
|
||
|
||
const handleLoadEnv = async () => {
|
||
setLoadingEnv(true);
|
||
try {
|
||
const data = await api.get('/admin/smtp/env');
|
||
setForm(f => {
|
||
const next = {
|
||
...f,
|
||
host: data.host || f.host,
|
||
port: data.port || f.port,
|
||
email: data.email || f.email,
|
||
username: data.username || f.username,
|
||
password: '',
|
||
};
|
||
clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(() => doSave(next), 1500);
|
||
return next;
|
||
});
|
||
if (data.hasPassword) { setHasPassword(true); hasPasswordRef.current = true; }
|
||
} catch (e) {
|
||
setResult({ ok: false, msg: 'Impossible de charger les valeurs .env.' });
|
||
} finally {
|
||
setLoadingEnv(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="account-section">
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||
<h2 style={{ margin: 0 }}>SMTP</h2>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
{saveStatus === 'saving' && (
|
||
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>Sauvegarde…</span>
|
||
)}
|
||
{saveStatus === 'saved' && (
|
||
<span style={{ fontSize: '0.82rem', color: 'var(--success, #15803d)' }}>✓ Sauvegardé</span>
|
||
)}
|
||
{saveStatus === 'error' && (
|
||
<span style={{ fontSize: '0.82rem', color: 'var(--danger, #dc2626)' }}>Erreur</span>
|
||
)}
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={() => handleLoadEnv()}
|
||
disabled={loadingEnv}
|
||
style={{ fontSize: '0.82rem' }}
|
||
>
|
||
{loadingEnv ? 'Chargement…' : 'Charger depuis .env'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p style={{ color: 'var(--text-muted)', marginBottom: 24 }}>
|
||
Configuration du serveur de messagerie sortant. Utilisée pour les alertes, rapports et réinitialisation de mot de passe.
|
||
</p>
|
||
|
||
{result && (
|
||
<div
|
||
className={result.ok ? 'alert-success' : 'alert-error'}
|
||
style={{
|
||
padding: '10px 14px',
|
||
borderRadius: 6,
|
||
marginBottom: 16,
|
||
background: result.ok ? 'var(--success-bg, #ecfdf5)' : 'var(--danger-bg, #fef2f2)',
|
||
color: result.ok ? 'var(--success, #15803d)' : 'var(--danger, #dc2626)',
|
||
border: `1px solid ${result.ok ? 'var(--success, #86efac)' : 'var(--danger, #fca5a5)'}`,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<span>{result.msg}</span>
|
||
<button onClick={() => setResult(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', fontSize: 16 }}>×</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="card" style={{ padding: '0 24px' }}>
|
||
|
||
{/* Activer */}
|
||
<SettingRow
|
||
label="Activer"
|
||
description="Active SMTP. Activez ceci uniquement si vous avez saisi l'hôte, le port, le courriel, l'utilisateur et son mot de passe."
|
||
>
|
||
<Toggle id="smtp-enabled" checked={form.enabled} onChange={v => set('enabled', v)} />
|
||
</SettingRow>
|
||
|
||
{/* Hôte */}
|
||
<SettingRow label="Hôte" description="Nom du serveur SMTP">
|
||
<input
|
||
className="form-input"
|
||
type="text"
|
||
value={form.host}
|
||
onChange={e => set('host', e.target.value)}
|
||
placeholder="smtp.example.com"
|
||
style={{ width: 280 }}
|
||
/>
|
||
</SettingRow>
|
||
|
||
{/* Port */}
|
||
<SettingRow label="Port" description="Port du serveur SMTP">
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
value={form.port}
|
||
onChange={e => set('port', parseInt(e.target.value, 10) || 587)}
|
||
min={1}
|
||
max={65535}
|
||
style={{ width: 120 }}
|
||
/>
|
||
</SettingRow>
|
||
|
||
{/* Connexion sécurisée (TLS) */}
|
||
<SettingRow label="Connexion sécurisée (TLS)" description="Utiliser TLS dès la connexion (port 465). Désactivez pour STARTTLS (port 587).">
|
||
<Toggle id="smtp-secure" checked={form.secure} onChange={v => set('secure', v)} />
|
||
</SettingRow>
|
||
|
||
{/* Courriel */}
|
||
<SettingRow label="Courriel" description="Adresse email à partir de laquelle les courriels sont envoyés">
|
||
<input
|
||
className="form-input"
|
||
type="email"
|
||
value={form.email}
|
||
onChange={e => set('email', e.target.value)}
|
||
placeholder="no-reply@example.com"
|
||
style={{ width: 280 }}
|
||
/>
|
||
</SettingRow>
|
||
|
||
{/* Nom d'utilisateur */}
|
||
<SettingRow label="Nom d'utilisateur" description="Nom d'utilisateur du serveur SMTP">
|
||
<input
|
||
className="form-input"
|
||
type="text"
|
||
value={form.username}
|
||
onChange={e => set('username', e.target.value)}
|
||
placeholder="user@example.com"
|
||
autoComplete="off"
|
||
style={{ width: 280 }}
|
||
/>
|
||
</SettingRow>
|
||
|
||
{/* Mot de passe */}
|
||
<SettingRow
|
||
label="Mot de passe"
|
||
description={hasPassword && !form.password ? 'Un mot de passe est déjà enregistré. Laissez vide pour le conserver.' : 'Mot de passe du serveur SMTP'}
|
||
>
|
||
<div style={{ position: 'relative', width: 280 }}>
|
||
<input
|
||
className="form-input"
|
||
type={showPassword ? 'text' : 'password'}
|
||
value={form.password}
|
||
onChange={e => { passwordTyped.current = true; set('password', e.target.value); }}
|
||
placeholder={hasPassword ? '••••••••' : 'Mot de passe'}
|
||
autoComplete="new-password"
|
||
style={{ width: '100%', paddingRight: 36 }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPassword(s => !s)}
|
||
style={{
|
||
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
color: 'var(--text-muted)', padding: 0, display: 'flex',
|
||
}}
|
||
tabIndex={-1}
|
||
>
|
||
<IconEye open={showPassword} />
|
||
</button>
|
||
</div>
|
||
</SettingRow>
|
||
|
||
{/* Certificats non autorisés */}
|
||
<SettingRow
|
||
label="Faire confiance aux certificats de serveurs SMTP non autorisés"
|
||
description="Ne permettez ceci que si vous avez besoin de faire confiance aux certificats autosignés."
|
||
>
|
||
<Toggle id="smtp-allowUnauth" checked={form.allowUnauth} onChange={v => set('allowUnauth', v)} />
|
||
</SettingRow>
|
||
|
||
</div>
|
||
|
||
|
||
{/* Section email de test */}
|
||
<div className="card" style={{ marginTop: 24, padding: '20px 24px' }}>
|
||
<h3 style={{ margin: '0 0 12px' }}>Envoyer un email de test</h3>
|
||
<p style={{ color: 'var(--text-muted)', marginBottom: 16, fontSize: '0.9rem' }}>
|
||
Vérifiez que votre configuration SMTP fonctionne en envoyant un email de test. Sauvegardez d'abord vos paramètres.
|
||
</p>
|
||
{testResult && (
|
||
<div style={{
|
||
padding: '10px 14px',
|
||
borderRadius: 6,
|
||
marginBottom: 14,
|
||
background: testResult.ok ? 'var(--success-bg, #ecfdf5)' : 'var(--danger-bg, #fef2f2)',
|
||
color: testResult.ok ? 'var(--success, #15803d)' : 'var(--danger, #dc2626)',
|
||
border: `1px solid ${testResult.ok ? 'var(--success, #86efac)' : 'var(--danger, #fca5a5)'}`,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
}}>
|
||
<span>{testResult.msg}</span>
|
||
<button onClick={() => setTestResult(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', fontSize: 16 }}>×</button>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||
<input
|
||
className="form-input"
|
||
type="email"
|
||
value={testEmail}
|
||
onChange={e => setTestEmail(e.target.value)}
|
||
placeholder="destinataire@example.com"
|
||
style={{ flex: 1, maxWidth: 320 }}
|
||
onKeyDown={e => e.key === 'Enter' && handleTest()}
|
||
/>
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={() => handleTest()}
|
||
disabled={testing || !testEmail}
|
||
>
|
||
{testing ? 'Envoi…' : 'Envoyer un courriel de test'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|