Gestion des utilisateurs

This commit is contained in:
2026-06-15 07:32:36 +02:00
parent 414c8dbb74
commit f3c5387a92
11 changed files with 1163 additions and 128 deletions
+22
View File
@@ -1800,4 +1800,26 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_uid ON two_fa_trusted_devices(
console.log('[DB] Migrations 2FA OK');
// ── Table invitations ────────────────────────────────────────────────────────
{
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='invitations'").get();
if (!tables) {
db.exec(`
CREATE TABLE invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_invitations_token ON invitations(token)');
console.log('[DB] Table invitations créée');
}
}
export default db;
+20 -1
View File
@@ -48,7 +48,7 @@ const router = Router();
/** Liste tous les utilisateurs */
router.get('/users', (req, res) => {
const users = db.prepare(`
SELECT id, email, display_name, role, email_verified, totp_enabled, created_at
SELECT id, email, display_name, role, email_verified, totp_enabled, status, created_at
FROM users
ORDER BY id ASC
`).all();
@@ -96,6 +96,25 @@ router.post('/users', (req, res, next) => {
} catch (e) { next(e); }
});
/** Modifie le statut d'un utilisateur (active / deactivated / locked) */
const PatchStatusSchema = z.object({
status: z.enum(['active', 'deactivated', 'locked']),
});
router.patch('/users/:id/status', (req, res, next) => {
try {
const { status } = PatchStatusSchema.parse(req.body);
const targetId = Number(req.params.id);
if (targetId === req.user.id) {
throw new HttpError(400, 'Vous ne pouvez pas modifier votre propre statut');
}
const r = db.prepare("UPDATE users SET status=?, updated_at=datetime('now') WHERE id=?")
.run(status, targetId);
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
res.json({ id: targetId, status });
} catch (e) { next(e); }
});
/** Modifie le rôle d'un utilisateur */
const PatchRoleSchema = z.object({
role: z.enum(['user', 'admin']),
+8 -1
View File
@@ -101,13 +101,20 @@ router.post('/login', async (req, res, next) => {
try {
const body = LoginSchema2FA.parse(req.body);
const user = db
.prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled FROM users WHERE email = ?')
.prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled, status FROM users WHERE email = ?')
.get(body.email);
if (!user) throw new HttpError(401, 'Invalid credentials');
const ok = bcrypt.compareSync(body.password, user.password_hash);
if (!ok) throw new HttpError(401, 'Invalid credentials');
if (user.status === 'deactivated') {
return res.status(403).json({ error: 'Ce compte a été désactivé. Contactez un administrateur.', code: 'ACCOUNT_DEACTIVATED' });
}
if (user.status === 'locked') {
return res.status(403).json({ error: 'Ce compte est verrouillé. Contactez un administrateur.', code: 'ACCOUNT_LOCKED' });
}
if (!user.email_verified) {
return res.status(403).json({
error: 'Veuillez vérifier votre adresse email avant de vous connecter.',
+151
View File
@@ -0,0 +1,151 @@
/**
* /api/invitations
*
* Routes publiques (pas de requireAuth) :
* GET /:token — valide le token, retourne email + role
* POST /:token/register — finalise l'inscription
*
* Route admin :
* POST / — envoie une invitation (requireAdmin dans server.js)
* GET / — liste les invitations en cours (requireAdmin)
* DELETE /:id — révoque une invitation (requireAdmin)
*/
import { Router } from 'express';
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
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();
// ── Schémas Zod ──────────────────────────────────────────────────────────────
const InviteSchema = z.object({
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
const RegisterSchema = z.object({
displayName: z.string().max(100).optional(),
password: z.string().min(8),
});
// ── Admin : envoyer une invitation ───────────────────────────────────────────
router.post('/', async (req, res, next) => {
try {
const { email, role } = InviteSchema.parse(req.body);
// Vérifier que l'email n'est pas déjà utilisé
const existing = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
if (existing) throw new HttpError(409, 'Un compte avec cet email existe déjà.');
// Révoquer toute invitation en cours non utilisée pour cet email
db.prepare("DELETE FROM invitations WHERE LOWER(email) = LOWER(?) AND used_at IS NULL").run(email);
// Créer le token (7 jours)
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
db.prepare(`
INSERT INTO invitations (token, email, role, invited_by, expires_at)
VALUES (?, ?, ?, ?, ?)
`).run(token, email, role, req.user.id, expiresAt);
// Envoyer l'email
const cfg = getSmtpConfig();
const appUrl = cfg.appUrl || '';
const inviteUrl = `${appUrl}/invitation/${token}`;
const roleLabel = role === 'admin' ? 'Administrateur' : 'Utilisateur';
await sendMail({
to: email,
subject: `Invitation à rejoindre ${cfg.appName}`,
html: buildEmailHtml({
title: 'Vous avez été invité',
body: `
<p>Vous avez reçu une invitation à rejoindre <strong>${cfg.appName}</strong>
en tant que <strong>${roleLabel}</strong>.</p>
<p>Cliquez sur le bouton ci-dessous pour créer votre compte.
Ce lien est valable <strong>7 jours</strong>.</p>
`,
ctaLabel: 'Créer mon compte',
ctaUrl: inviteUrl,
}),
});
res.json({ ok: true, email, expiresAt });
} catch (e) { next(e); }
});
// ── Admin : liste des invitations ─────────────────────────────────────────────
router.get('/', (req, res, next) => {
try {
const rows = db.prepare(`
SELECT i.id, i.email, i.role, i.expires_at, i.used_at, i.created_at,
u.display_name AS invited_by_name, u.email AS invited_by_email
FROM invitations i
LEFT JOIN users u ON u.id = i.invited_by
ORDER BY i.created_at DESC
LIMIT 200
`).all();
res.json(rows);
} catch (e) { next(e); }
});
// ── Admin : révoquer une invitation ───────────────────────────────────────────
router.delete('/:id', (req, res, next) => {
try {
const r = db.prepare('DELETE FROM invitations WHERE id = ? AND used_at IS NULL').run(Number(req.params.id));
if (r.changes === 0) throw new HttpError(404, 'Invitation introuvable ou déjà utilisée.');
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Public : valider un token ─────────────────────────────────────────────────
router.get('/:token', (req, res, next) => {
try {
const inv = db.prepare('SELECT * FROM invitations WHERE token = ?').get(req.params.token);
if (!inv) throw new HttpError(404, 'Lien d\'invitation invalide.');
if (inv.used_at) throw new HttpError(410, 'Ce lien a déjà été utilisé.');
if (new Date(inv.expires_at) < new Date()) throw new HttpError(410, 'Ce lien a expiré.');
res.json({ email: inv.email, role: inv.role, expiresAt: inv.expires_at });
} catch (e) { next(e); }
});
// ── Public : finaliser l'inscription ─────────────────────────────────────────
router.post('/:token/register', async (req, res, next) => {
try {
const { displayName, password } = RegisterSchema.parse(req.body);
const inv = db.prepare('SELECT * FROM invitations WHERE token = ?').get(req.params.token);
if (!inv) throw new HttpError(404, 'Lien d\'invitation invalide.');
if (inv.used_at) throw new HttpError(410, 'Ce lien a déjà été utilisé.');
if (new Date(inv.expires_at) < new Date()) throw new HttpError(410, 'Ce lien a expiré.');
// Vérifier que l'email n'est pas déjà pris (race condition)
const existing = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(inv.email);
if (existing) throw new HttpError(409, 'Un compte avec cet email existe déjà.');
const hash = await bcrypt.hash(password, 12);
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, 'active', datetime('now'), datetime('now'))
`).run(inv.email, hash, displayName || null, inv.role);
// Marquer l'invitation comme utilisée
db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id);
res.json({ ok: true, userId: result.lastInsertRowid });
} catch (e) { next(e); }
});
export default router;
+4
View File
@@ -33,6 +33,7 @@ import { errorHandler } from './middleware/errorHandler.js';
import { requireAuth, requireAdmin } from './middleware/auth.js';
import { startAutoStatutJob } from './jobs/autoStatut.js';
import adminRouter from './routes/admin.js';
import invitationsRouter from './routes/invitations.js';
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
import referentielRouter from './routes/referentiel.js';
import referentielPublicRouter from './routes/referentielPublic.js';
@@ -108,6 +109,9 @@ app.use('/api/comptes', requireAuth, comptesRouter);
app.use('/api/preferences', requireAuth, preferencesRouter);
app.use('/api/icons', requireAuth, iconsRouter);
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
// Invitations : routes admin protégées + routes publiques (register/validate)
app.use('/api/admin/invitations', requireAuth, requireAdmin, invitationsRouter);
app.use('/api/invitations', invitationsRouter);
app.use('/api/taux-credit-impot', requireAuth, tauxCreditImpotRouter);
app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter);
app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);