diff --git a/backend/src/routes/invitations.js b/backend/src/routes/invitations.js index 525947f..3049a3c 100644 --- a/backend/src/routes/invitations.js +++ b/backend/src/routes/invitations.js @@ -8,7 +8,7 @@ * Route admin : * POST / — envoie une invitation (requireAdmin dans server.js) * GET / — liste les invitations en cours (requireAdmin) - * DELETE /:id — révoque une invitation (requireAdmin) + * DELETE /:id — révoque une invitation (supprime aussi le user en attente) */ import { Router } from 'express'; @@ -21,7 +21,18 @@ import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js'; const router = Router(); -// ── Schémas Zod ────────────────────────────────────────────────────────────── +// ── 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(), @@ -39,14 +50,29 @@ 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à.'); + // 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 le token (7 jours) + // 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(); @@ -56,10 +82,10 @@ router.post('/', async (req, res, next) => { `).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'; + const cfg = getSmtpConfig(); + const appUrl = cfg.appUrl || ''; + const inviteUrl = `${appUrl}/invitation/${token}`; + const roleLabel = role === 'admin' ? 'Administrateur' : 'Utilisateur'; await sendMail({ to: email, @@ -101,8 +127,13 @@ router.get('/', (req, res, next) => { 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.'); + 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); } }); @@ -112,8 +143,8 @@ router.delete('/:id', (req, res, next) => { 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 (!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); } @@ -126,25 +157,30 @@ router.post('/:token/register', async (req, res, next) => { 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 (!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à.'); + // 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); - 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); + // 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); - res.json({ ok: true, userId: result.lastInsertRowid }); + res.json({ ok: true, userId: pendingUser.id }); } catch (e) { next(e); } });