Gestion des utilisateurs
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user