/** * /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 (supprime aussi le user en attente) */ 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'; import { audit } from '../utils/audit.js'; const router = Router(); // ── Helpers ─────────────────────────────────────────────────────────────────── /** Dérive un nom affiché depuis une adresse email. */ function nameFromEmail(email) { const local = email.split('@')[0]; return local .replace(/[._\-+]+/g, ' ') .replace(/\b\w/g, c => c.toUpperCase()) .trim(); } // ── 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); // Compte existant et vérifié → refus const existingUser = db.prepare('SELECT id, email_verified FROM users WHERE LOWER(email) = LOWER(?)').get(email); if (existingUser?.email_verified) { throw new HttpError(409, 'Un compte actif 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 (ou mettre à jour) le user en attente if (existingUser) { // Compte déjà en attente d'une ancienne invitation → on met à jour le rôle db.prepare("UPDATE users SET role=?, updated_at=datetime('now') WHERE id=?").run(role, existingUser.id); } else { // Nouveau user : mot de passe inutilisable (token aléatoire hashé) const unusableHash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10); db.prepare(` INSERT INTO users (email, password_hash, display_name, role, email_verified, status, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 'active', datetime('now'), datetime('now')) `).run(email, unusableHash, nameFromEmail(email), role); } // Créer le token d'invitation (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: `

Vous avez reçu une invitation à rejoindre ${cfg.appName} en tant que ${roleLabel}.

Cliquez sur le bouton ci-dessous pour créer votre compte. Ce lien est valable 7 jours.

`, ctaLabel: 'Créer mon compte', ctaUrl: inviteUrl, }), }); audit(req, { action: 'invitation_sent', category: 'invitation', actorId: req.user.id, details: { email, role, expiresAt } }); 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 inv = db.prepare('SELECT * FROM invitations WHERE id = ? AND used_at IS NULL').get(Number(req.params.id)); if (!inv) throw new HttpError(404, 'Invitation introuvable ou déjà utilisée.'); // Supprimer le user en attente associé (non vérifié) db.prepare('DELETE FROM users WHERE LOWER(email) = LOWER(?) AND email_verified = 0').run(inv.email); db.prepare('DELETE FROM invitations WHERE id = ?').run(inv.id); 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é.'); // Récupérer le user en attente créé à l'invitation const pendingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND email_verified = 0').get(inv.email); if (!pendingUser) throw new HttpError(409, 'Ce compte a déjà été activé ou supprimé.'); const hash = await bcrypt.hash(password, 12); // Finaliser le compte : mot de passe, nom, vérification db.prepare(` UPDATE users SET password_hash = ?, display_name = ?, email_verified = 1, updated_at = datetime('now') WHERE id = ? `).run(hash, displayName || nameFromEmail(inv.email), pendingUser.id); // Marquer l'invitation comme utilisée db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id); audit(req, { action: 'invitation_accepted', category: 'invitation', targetUserId: pendingUser.id, details: { email: inv.email, role: inv.role } }); res.json({ ok: true, userId: pendingUser.id }); } catch (e) { next(e); } }); export default router;