Maj pour invitation
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
* Route admin :
|
* Route admin :
|
||||||
* POST / — envoie une invitation (requireAdmin dans server.js)
|
* POST / — envoie une invitation (requireAdmin dans server.js)
|
||||||
* GET / — liste les invitations en cours (requireAdmin)
|
* 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';
|
import { Router } from 'express';
|
||||||
@@ -21,7 +21,18 @@ import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
|
|||||||
|
|
||||||
const router = Router();
|
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({
|
const InviteSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -39,14 +50,29 @@ router.post('/', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const { email, role } = InviteSchema.parse(req.body);
|
const { email, role } = InviteSchema.parse(req.body);
|
||||||
|
|
||||||
// Vérifier que l'email n'est pas déjà utilisé
|
// Compte existant et vérifié → refus
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
|
const existingUser = db.prepare('SELECT id, email_verified FROM users WHERE LOWER(email) = LOWER(?)').get(email);
|
||||||
if (existing) throw new HttpError(409, 'Un compte avec cet email existe déjà.');
|
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
|
// 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);
|
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 token = crypto.randomBytes(32).toString('hex');
|
||||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
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);
|
`).run(token, email, role, req.user.id, expiresAt);
|
||||||
|
|
||||||
// Envoyer l'email
|
// Envoyer l'email
|
||||||
const cfg = getSmtpConfig();
|
const cfg = getSmtpConfig();
|
||||||
const appUrl = cfg.appUrl || '';
|
const appUrl = cfg.appUrl || '';
|
||||||
const inviteUrl = `${appUrl}/invitation/${token}`;
|
const inviteUrl = `${appUrl}/invitation/${token}`;
|
||||||
const roleLabel = role === 'admin' ? 'Administrateur' : 'Utilisateur';
|
const roleLabel = role === 'admin' ? 'Administrateur' : 'Utilisateur';
|
||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
@@ -101,8 +127,13 @@ router.get('/', (req, res, next) => {
|
|||||||
|
|
||||||
router.delete('/:id', (req, res, next) => {
|
router.delete('/:id', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = db.prepare('DELETE FROM invitations WHERE id = ? AND used_at IS NULL').run(Number(req.params.id));
|
const inv = db.prepare('SELECT * FROM invitations WHERE id = ? AND used_at IS NULL').get(Number(req.params.id));
|
||||||
if (r.changes === 0) throw new HttpError(404, 'Invitation introuvable ou déjà utilisée.');
|
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 });
|
res.json({ ok: true });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
@@ -112,8 +143,8 @@ router.delete('/:id', (req, res, next) => {
|
|||||||
router.get('/:token', (req, res, next) => {
|
router.get('/:token', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const inv = db.prepare('SELECT * FROM invitations WHERE token = ?').get(req.params.token);
|
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) 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.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é.');
|
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 });
|
res.json({ email: inv.email, role: inv.role, expiresAt: inv.expires_at });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
@@ -126,25 +157,30 @@ router.post('/:token/register', async (req, res, next) => {
|
|||||||
const { displayName, password } = RegisterSchema.parse(req.body);
|
const { displayName, password } = RegisterSchema.parse(req.body);
|
||||||
|
|
||||||
const inv = db.prepare('SELECT * FROM invitations WHERE token = ?').get(req.params.token);
|
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) 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.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é.');
|
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)
|
// Récupérer le user en attente créé à l'invitation
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(inv.email);
|
const pendingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND email_verified = 0').get(inv.email);
|
||||||
if (existing) throw new HttpError(409, 'Un compte avec cet email existe déjà.');
|
if (!pendingUser) throw new HttpError(409, 'Ce compte a déjà été activé ou supprimé.');
|
||||||
|
|
||||||
const hash = await bcrypt.hash(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
const result = db.prepare(`
|
// Finaliser le compte : mot de passe, nom, vérification
|
||||||
INSERT INTO users (email, password_hash, display_name, role, email_verified, status, created_at, updated_at)
|
db.prepare(`
|
||||||
VALUES (?, ?, ?, ?, 1, 'active', datetime('now'), datetime('now'))
|
UPDATE users
|
||||||
`).run(inv.email, hash, displayName || null, inv.role);
|
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
|
// Marquer l'invitation comme utilisée
|
||||||
db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id);
|
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); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user