Maj Section Général

This commit is contained in:
2026-06-15 23:03:37 +02:00
parent 01526a5551
commit 032a370e7d
15 changed files with 492 additions and 57 deletions
+14
View File
@@ -1858,4 +1858,18 @@ console.log('[DB] Migrations 2FA OK');
}
// ── Migration : colonnes paramètres généraux dans smtp_config ─────────────
{
const cols = db.prepare("PRAGMA table_info(smtp_config)").all().map(c => c.name);
if (!cols.includes('allow_registration')) {
db.exec("ALTER TABLE smtp_config ADD COLUMN allow_registration INTEGER NOT NULL DEFAULT 1");
console.log('[DB] Colonne smtp_config.allow_registration ajoutée');
}
if (!cols.includes('min_password_length')) {
db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8");
console.log('[DB] Colonne smtp_config.min_password_length ajoutée');
}
}
export default db;
+73
View File
@@ -0,0 +1,73 @@
/**
* /api/admin/general — Paramètres généraux de l'application
* Protégé par requireAuth + requireAdmin (appliqués dans server.js).
*
* GET / — retourne les 4 champs généraux
* PATCH / — met à jour un ou plusieurs champs
*
* Route publique complémentaire : /api/app-info (server.js) expose
* allowRegistration et minPasswordLength sans auth.
*/
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
const router = Router();
function ensureRow() {
const exists = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get();
if (!exists) {
db.prepare(`
INSERT INTO smtp_config (id, enabled, host, port, secure, email, username, password,
allow_unauth, app_name, app_url, allow_registration, min_password_length)
VALUES (1, 0, '', 587, 0, '', '', '', 0, 'Crowdlending Tracker', '', 1, 8)
`).run();
}
}
router.get('/', (_req, res, next) => {
try {
ensureRow();
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
res.json({
appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '',
allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8,
});
} catch (e) { next(e); }
});
const PatchSchema = z.object({
appName: z.string().min(1).max(100).optional(),
appUrl: z.string().max(500).optional(),
allowRegistration: z.boolean().optional(),
minPasswordLength: z.number().int().min(6).max(64).optional(),
});
router.patch('/', (req, res, next) => {
try {
ensureRow();
const body = PatchSchema.parse(req.body);
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
db.prepare(`
UPDATE smtp_config SET
app_name = ?,
app_url = ?,
allow_registration = ?,
min_password_length = ?
WHERE id = 1
`).run(
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
body.appUrl !== undefined ? body.appUrl : (row.app_url || ''),
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
);
res.json({ ok: true });
} catch (e) { next(e); }
});
export default router;
+132
View File
@@ -0,0 +1,132 @@
/**
* /api/admin/smtp — Gestion de la configuration SMTP
* Protégé par requireAuth + requireAdmin (appliqués dans server.js).
*/
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
const router = Router();
// ── Helpers ─────────────────────────────────────────────────────────────────
function ensureRow() {
const exists = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get();
if (!exists) {
db.prepare(`
INSERT INTO smtp_config (id, enabled, host, port, secure, email, username, password,
allow_unauth, app_name, app_url, allow_registration, min_password_length)
VALUES (1, 0, '', 587, 0, '', '', '', 0, 'Crowdlending Tracker', '', 1, 8)
`).run();
}
}
// ── GET /admin/smtp ─────────────────────────────────────────────────────────
router.get('/', (_req, res, next) => {
try {
ensureRow();
const row = db.prepare('SELECT * FROM smtp_config WHERE id = 1').get();
res.json({
enabled: !!row.enabled,
host: row.host || '',
port: row.port || 587,
secure: !!row.secure,
email: row.email || '',
username: row.username || '',
hasPassword: !!(row.password),
allowUnauth: !!row.allow_unauth,
appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '',
allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8,
});
} catch (e) { next(e); }
});
// ── PUT /admin/smtp ─────────────────────────────────────────────────────────
const SmtpSchema = z.object({
enabled: z.boolean().optional(),
host: z.string().optional(),
port: z.number().int().min(1).max(65535).optional(),
secure: z.boolean().optional(),
email: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
allowUnauth: z.boolean().optional(),
appName: z.string().max(100).optional(),
appUrl: z.string().max(500).optional(),
});
router.put('/', (req, res, next) => {
try {
ensureRow();
const body = SmtpSchema.parse(req.body);
const row = db.prepare('SELECT * FROM smtp_config WHERE id = 1').get();
const updates = {
enabled: body.enabled !== undefined ? (body.enabled ? 1 : 0) : row.enabled,
host: body.host !== undefined ? body.host : row.host,
port: body.port !== undefined ? body.port : row.port,
secure: body.secure !== undefined ? (body.secure ? 1 : 0) : row.secure,
email: body.email !== undefined ? body.email : row.email,
username: body.username !== undefined ? body.username : row.username,
password: body.password !== undefined ? body.password : row.password,
allow_unauth: body.allowUnauth !== undefined ? (body.allowUnauth ? 1 : 0) : row.allow_unauth,
app_name: body.appName !== undefined ? body.appName : row.app_name,
app_url: body.appUrl !== undefined ? body.appUrl : row.app_url,
};
db.prepare(`
UPDATE smtp_config SET
enabled=?, host=?, port=?, secure=?, email=?, username=?, password=?,
allow_unauth=?, app_name=?, app_url=?
WHERE id=1
`).run(
updates.enabled, updates.host, updates.port, updates.secure,
updates.email, updates.username, updates.password, updates.allow_unauth,
updates.app_name, updates.app_url,
);
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── POST /admin/smtp/test ───────────────────────────────────────────────────
router.post('/test', async (req, res, next) => {
try {
const { to } = z.object({ to: z.string().email() }).parse(req.body);
const cfg = getSmtpConfig();
if (!cfg.host) throw new HttpError(400, 'Aucun serveur SMTP configuré.');
await sendMail({
to,
subject: `Test SMTP — ${cfg.appName}`,
html: buildEmailHtml({
title: 'Email de test',
body: `<p>Si vous recevez cet email, votre configuration SMTP fonctionne correctement.</p>`,
}),
});
res.json({ ok: true, msg: `Email de test envoyé à ${to}.` });
} catch (e) { next(e); }
});
// ── GET /admin/smtp/env ─────────────────────────────────────────────────────
router.get('/env', (_req, res) => {
res.json({
host: process.env.SMTP_HOST || '',
port: parseInt(process.env.SMTP_PORT || '587', 10),
email: process.env.SMTP_EMAIL || '',
username: process.env.SMTP_USERNAME || '',
appName: process.env.APP_NAME || '',
appUrl: process.env.APP_URL || '',
});
});
export default router;
+10 -3
View File
@@ -35,6 +35,8 @@ import { startAutoStatutJob } from './jobs/autoStatut.js';
import adminRouter from './routes/admin.js';
import invitationsRouter from './routes/invitations.js';
import auditLogsRouter from './routes/auditLogs.js';
import smtpRouter from './routes/smtp.js';
import generalRouter from './routes/general.js';
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
import referentielRouter from './routes/referentiel.js';
import referentielPublicRouter from './routes/referentielPublic.js';
@@ -78,12 +80,15 @@ app.get('/api/app-info', (_, res) => {
try {
const cfg = getSmtpConfig();
const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
const row = db.prepare('SELECT allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
res.json({
appName: cfg.appName || 'Crowdlending Tracker',
iconUrl: icon ? `/api/icons-files/${icon.filename}` : null,
appName: cfg.appName || 'Crowdlending Tracker',
iconUrl: icon ? `/api/icons-files/${icon.filename}` : null,
allowRegistration: row ? row.allow_registration !== 0 : true,
minPasswordLength: row ? (row.min_password_length || 8) : 8,
});
} catch {
res.json({ appName: 'Crowdlending Tracker', iconUrl: null });
res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8 });
}
});
@@ -111,6 +116,8 @@ app.use('/api/preferences', requireAuth, preferencesRouter);
app.use('/api/icons', requireAuth, iconsRouter);
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
app.use('/api/admin/audit-logs', requireAuth, requireAdmin, auditLogsRouter);
app.use('/api/admin/smtp', requireAuth, requireAdmin, smtpRouter);
app.use('/api/admin/general', requireAuth, requireAdmin, generalRouter);
// Invitations : routes admin protégées + routes publiques (register/validate)
app.use('/api/admin/invitations', requireAuth, requireAdmin, invitationsRouter);
app.use('/api/invitations', invitationsRouter);
+10 -1
View File
@@ -1,4 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useAuth } from './context/AuthContext.jsx';
import Login from './pages/Login.jsx';
import Register from './pages/Register.jsx';
@@ -38,10 +39,18 @@ function AdminOnly({ children }) {
}
export default function App() {
const [allowRegistration, setAllowRegistration] = useState(true);
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => {
if (d.allowRegistration === false) setAllowRegistration(false);
}).catch(() => {});
}, []);
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/register" element={allowRegistration ? <Register /> : <Navigate to="/login" replace />} />
<Route path="/invitation/:token" element={<InvitationRegister />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
+7 -4
View File
@@ -7,13 +7,15 @@
* Affiche uniquement quand password est non vide.
*/
const RULES = [
{ id: 'len', label: '8 caractères minimum', test: p => p.length >= 8 },
function buildRules(minLen) {
return [
{ id: 'len', label: `${minLen} caractères minimum`, test: p => p.length >= minLen },
{ id: 'upper', label: 'Une lettre majuscule (AZ)', test: p => /[A-Z]/.test(p) },
{ id: 'lower', label: 'Une lettre minuscule (az)', test: p => /[a-z]/.test(p) },
{ id: 'digit', label: 'Un chiffre (09)', test: p => /[0-9]/.test(p) },
{ id: 'special', label: 'Un caractère spécial (!@#…)', test: p => /[^A-Za-z0-9]/.test(p) },
];
];
}
const LEVELS = [
{ min: 0, label: 'Très faible', color: '#ef4444' },
@@ -32,9 +34,10 @@ function getLevel(score) {
return LEVELS[0];
}
export default function PasswordStrength({ password }) {
export default function PasswordStrength({ password, minLength = 8 }) {
if (!password) return null;
const RULES = buildRules(minLength);
const results = RULES.map(r => ({ ...r, ok: r.test(password) }));
const score = results.filter(r => r.ok).length;
const level = getLevel(score);
+4
View File
@@ -5,6 +5,7 @@ import AuditLogsSection from './admin/AuditLogsSection.jsx';
import JobLogsSection from './admin/JobLogsSection.jsx';
import IconsSection from './admin/IconsSection.jsx';
import SmtpSection from './admin/SmtpSection.jsx';
import GeneralSection from './admin/GeneralSection.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
@@ -13,12 +14,14 @@ function IconShield() { return <svg width="15" height="15" viewBox="0 0 24 24"
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; }
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
const NAV = [
{
group: 'Administration de la plateforme',
items: [
{ id: 'general', label: 'Général', icon: <IconSettings /> },
{ id: 'users', label: 'Utilisateurs', icon: <IconUsers /> },
{ id: 'audit-logs', label: 'Audit', icon: <IconShield /> },
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
@@ -70,6 +73,7 @@ export default function Admin() {
))}
</aside>
<div className="account-content">
{section === 'general' && <GeneralSection />}
{section === 'users' && <UsersSection currentUserId={user?.id} />}
{section === 'audit-logs' && <AuditLogsSection />}
{section === 'job-logs' && <JobLogsSection />}
+4 -4
View File
@@ -29,7 +29,7 @@ function Wrap({ appInfo, children }) {
export default function InvitationRegister() {
const { token } = useParams();
const navigate = useNavigate();
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, minPasswordLength: 8 });
const [inv, setInv] = useState(null); // { email, role, expiresAt }
const [status, setStatus] = useState('loading'); // loading | valid | invalid | done
const [errMsg, setErrMsg] = useState('');
@@ -173,13 +173,13 @@ export default function InvitationRegister() {
<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={e => set('password', e.target.value)} style={{ width: '100%' }} />
<PasswordStrength password={form.password} />
<input className="form-input" type="password" required minLength={appInfo.minPasswordLength || 8} autoComplete="new-password" placeholder="••••••••" value={form.password} onChange={e => set('password', e.target.value)} style={{ width: '100%' }} />
<PasswordStrength password={form.password} minLength={appInfo.minPasswordLength || 8} />
</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={form.confirm} onChange={e => set('confirm', e.target.value)} style={{ width: '100%' }} />
<input className="form-input" type="password" required minLength={appInfo.minPasswordLength || 8} autoComplete="new-password" placeholder="••••••••" value={form.confirm} onChange={e => set('confirm', 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' }}>
+7 -5
View File
@@ -46,7 +46,7 @@ export default function Login() {
const { login, completeLogin } = useAuth();
const navigate = useNavigate();
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true });
// Étape : 'form' | 'method' | 'code'
const [step, setStep] = useState('form');
@@ -242,10 +242,12 @@ export default function Login() {
<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>
{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>
)}
</>
)}
+8 -1
View File
@@ -411,6 +411,13 @@ function TwoFASection({ user }) {
/* ── Sécurité — Mot de passe ─────────────────────────────────── */
function SecurityForm() {
const { updateUser } = useAuth();
const [minPasswordLength, setMinPasswordLength] = useState(8);
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => {
if (d.minPasswordLength) setMinPasswordLength(d.minPasswordLength);
}).catch(() => {});
}, []);
const [pwdForm, setPwdForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [pwdMsg, setPwdMsg] = useState(null);
@@ -471,7 +478,7 @@ function SecurityForm() {
<input type="password" required autoComplete="new-password"
value={pwdForm.newPassword}
onChange={e => setPwdForm({ ...pwdForm, newPassword: e.target.value })} />
<PasswordStrength password={pwdForm.newPassword} />
<PasswordStrength password={pwdForm.newPassword} minLength={minPasswordLength} />
</div>
<div>
<label>Confirmer le nouveau mot de passe</label>
+3 -3
View File
@@ -9,7 +9,7 @@ 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 });
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, minPasswordLength: 8 });
useEffect(() => {
fetch('/api/app-info')
@@ -211,14 +211,14 @@ export default function Register() {
<input
className="form-input"
type="password" required
minLength={8}
minLength={appInfo.minPasswordLength || 8}
autoComplete="new-password"
placeholder="••••••••"
value={form.password}
onChange={set('password')}
style={{ width: '100%' }}
/>
<PasswordStrength password={form.password} />
<PasswordStrength password={form.password} minLength={appInfo.minPasswordLength || 8} />
</div>
<button
+3 -3
View File
@@ -186,14 +186,14 @@ export default function ResetPassword() {
</label>
<input
className="form-input"
type="password" required minLength={8}
type="password" required minLength={appInfo.minPasswordLength || 8}
autoComplete="new-password"
placeholder="••••••••"
value={password}
onChange={e => { setPassword(e.target.value); setStatus(null); }}
style={{ width: '100%' }}
/>
<PasswordStrength password={password} />
<PasswordStrength password={password} minLength={appInfo.minPasswordLength || 8} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
@@ -202,7 +202,7 @@ export default function ResetPassword() {
</label>
<input
className="form-input"
type="password" required minLength={8}
type="password" required minLength={appInfo.minPasswordLength || 8}
autoComplete="new-password"
placeholder="••••••••"
value={password2}
+208
View File
@@ -0,0 +1,208 @@
/**
* GeneralSection — Paramètres généraux de l'application (Admin).
* GET/PATCH /api/admin/general
*/
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
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>
);
}
function SectionHeader({ title, description }) {
return (
<div style={{ marginBottom: 28 }}>
<h2 style={{ margin: '0 0 6px', fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>{title}</h2>
{description && <p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>{description}</p>}
</div>
);
}
const DEFAULT = {
appName: 'Crowdlending Tracker',
appUrl: '',
allowRegistration: true,
minPasswordLength: 8,
};
export default function GeneralSection() {
const [form, setForm] = useState(DEFAULT);
const [saved, setSaved] = useState(null); // 'ok' | 'err' | null
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/admin/general')
.then(d => setForm({
appName: d.appName || 'Crowdlending Tracker',
appUrl: d.appUrl || '',
allowRegistration: d.allowRegistration !== false,
minPasswordLength: d.minPasswordLength || 8,
}))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const save = async () => {
setBusy(true); setSaved(null);
try {
await api.patch('/admin/general', {
appName: form.appName.trim(),
appUrl: form.appUrl.trim(),
allowRegistration: form.allowRegistration,
minPasswordLength: form.minPasswordLength,
});
setSaved('ok');
setTimeout(() => setSaved(null), 3000);
} catch {
setSaved('err');
} finally {
setBusy(false);
}
};
if (loading) return <div style={{ padding: 32, color: 'var(--text-muted)' }}>Chargement</div>;
return (
<div>
<SectionHeader
title="Paramètres généraux"
description="Identité de l'application et règles d'accès."
/>
{/* Identité */}
<div className="card" style={{ marginBottom: 24 }}>
<h3 style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Identité</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>
Nom et URL affichés dans les emails et sur la page de connexion.
</p>
<SettingRow label="Nom de la plateforme" description="Affiché dans l'en-tête et le pied de page des emails envoyés.">
<input
className="form-input"
type="text"
maxLength={100}
value={form.appName}
onChange={e => set('appName', e.target.value)}
style={{ width: '100%' }}
/>
</SettingRow>
<SettingRow label="URL de la plateforme" description="Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://).">
<input
className="form-input"
type="url"
maxLength={500}
value={form.appUrl}
placeholder="https://mon-app.example.com"
onChange={e => set('appUrl', e.target.value)}
style={{ width: '100%' }}
/>
</SettingRow>
</div>
{/* Accès */}
<div className="card" style={{ marginBottom: 24 }}>
<h3 style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Accès</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>
Contrôle qui peut créer un compte sur l'application.
</p>
<SettingRow
label="Auto-inscription"
description="Permettre aux visiteurs de créer un compte sans invitation. Si désactivé, seules les invitations permettent de s'inscrire."
>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
<div
role="switch"
aria-checked={form.allowRegistration}
onClick={() => set('allowRegistration', !form.allowRegistration)}
style={{
width: 40, height: 22, borderRadius: 11,
background: form.allowRegistration ? 'var(--primary, #2563eb)' : 'var(--border)',
position: 'relative', cursor: 'pointer',
transition: 'background .2s',
flexShrink: 0,
}}
>
<div style={{
position: 'absolute', top: 3, left: form.allowRegistration ? 21 : 3,
width: 16, height: 16, borderRadius: '50%', background: '#fff',
transition: 'left .2s',
boxShadow: '0 1px 3px rgba(0,0,0,.2)',
}} />
</div>
<span style={{ fontSize: 13, color: 'var(--text)' }}>
{form.allowRegistration ? 'Activée' : 'Désactivée — invitation uniquement'}
</span>
</label>
</SettingRow>
</div>
{/* Sécurité */}
<div className="card" style={{ marginBottom: 28 }}>
<h3 style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Sécurité</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>
Règles appliquées à la création et la modification des mots de passe.
</p>
<SettingRow
label="Longueur minimale du mot de passe"
description="Nombre de caractères minimum requis. Appliqué sur tous les écrans de création et changement de mot de passe."
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
className="form-input"
type="number"
min={6}
max={64}
value={form.minPasswordLength}
onChange={e => set('minPasswordLength', Math.max(6, Math.min(64, parseInt(e.target.value) || 8)))}
style={{ width: 80 }}
/>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>caractères (min 6, max 64)</span>
</div>
</SettingRow>
</div>
{/* Bouton sauvegarde */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
onClick={save}
disabled={busy}
className="btn-primary"
style={{ padding: '9px 22px' }}
>
{busy ? 'Enregistrement…' : 'Enregistrer'}
</button>
{saved === 'ok' && (
<span style={{ fontSize: 13, color: 'var(--success, #16a34a)', display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Paramètres enregistrés
</span>
)}
{saved === 'err' && (
<span style={{ fontSize: 13, color: 'var(--danger, #dc2626)' }}>Erreur lors de la sauvegarde.</span>
)}
</div>
</div>
);
}
-31
View File
@@ -77,8 +77,6 @@ const DEFAULT_STATE = {
username: '',
password: '',
allowUnauth: false,
appName: 'Crowdlending Tracker',
appUrl: '',
};
export default function SmtpSection() {
@@ -109,8 +107,6 @@ export default function SmtpSection() {
username: data.username || '',
password: '',
allowUnauth: !!data.allowUnauth,
appName: data.appName || 'Crowdlending Tracker',
appUrl: data.appUrl || '',
});
setHasPassword(!!data.hasPassword);
hasPasswordRef.current = !!data.hasPassword;
@@ -239,33 +235,6 @@ export default function SmtpSection() {
<div className="card" style={{ padding: '0 24px' }}>
{/* Nom de la plateforme */}
<SettingRow label="Nom de la plateforme" description="Affiché dans l'en-tête et le pied de page des emails envoyés.">
<input
className="form-input"
type="text"
value={form.appName}
onChange={e => set('appName', e.target.value)}
placeholder="Crowdlending"
style={{ width: 280 }}
/>
</SettingRow>
{/* URL de la plateforme */}
<SettingRow label="URL de la plateforme" description="Utilisée pour le bouton de redirection dans les emails. Inclure le protocole (https://).">
<input
className="form-input"
type="url"
value={form.appUrl}
onChange={e => set('appUrl', e.target.value)}
placeholder="https://monapp.example.com"
style={{ width: 280 }}
/>
</SettingRow>
{/* Séparateur */}
<div style={{ borderBottom: '1px solid var(--border)', margin: '4px 0' }} />
{/* Activer */}
<SettingRow
label="Activer"
+9 -2
View File
@@ -308,6 +308,13 @@ function ResendInviteModal({ user, onClose, onSuccess, onError }) {
// ── Modale création utilisateur ────────────────────────────────────────────
function CreateUserModal({ open, onClose, onCreated }) {
const [minPasswordLength, setMinPasswordLength] = useState(8);
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => {
if (d.minPasswordLength) setMinPasswordLength(d.minPasswordLength);
}).catch(() => {});
}, []);
const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' });
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(null);
@@ -356,8 +363,8 @@ function CreateUserModal({ open, onClose, onCreated }) {
</div>
<div>
<label>Mot de passe *</label>
<input type="password" required minLength={8} autoComplete="new-password" value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
<PasswordStrength password={form.password} />
<input type="password" required minLength={minPasswordLength} autoComplete="new-password" value={form.password} onChange={e => set('password', e.target.value)} placeholder={`${minPasswordLength} caractères minimum`} />
<PasswordStrength password={form.password} minLength={minPasswordLength} />
</div>
<div>
<label>Rôle</label>