Maj Section Général
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
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
@@ -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,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 (A–Z)', test: p => /[A-Z]/.test(p) },
|
||||
{ id: 'lower', label: 'Une lettre minuscule (a–z)', test: p => /[a-z]/.test(p) },
|
||||
{ id: 'digit', label: 'Un chiffre (0–9)', 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);
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user