Checkpoint: Évolution v2 complète : nouveau profil Adhérent FEHAP, connexion locale par email/mot de passe (bcrypt), création manuelle d'utilisateurs par les gestionnaires, affectation d'établissements aux adhérents, page de choix de connexion (/login), refonte de la page Admin avec CRUD complet. 33 tests Vitest passés, zéro erreur TypeScript.

This commit is contained in:
Manus
2026-04-16 04:02:22 -04:00
parent 522d2e3c87
commit a58296774f
17 changed files with 2321 additions and 146 deletions

View File

@@ -69,7 +69,7 @@ export async function updateUserCgu(userId: number) {
await db.update(users).set({ cguAccepted: true, cguAcceptedAt: new Date() }).where(eq(users.id, userId));
}
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire") {
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire" | "adherent") {
const db = await getDb();
if (!db) return;
await db.update(users).set({ sonumRole }).where(eq(users.id, userId));
@@ -390,3 +390,206 @@ export async function getDemandeById(id: number) {
const result = await db.select().from(demandesContact).where(eq(demandesContact.id, id)).limit(1);
return result[0] ?? null;
}
// ─── Auth locale ──────────────────────────────────────────────────────────────
import { localCredentials, userEtablissements } from "../drizzle/schema";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
/** Crée un utilisateur local (sans openId OAuth) avec un mot de passe hashé. */
export async function createLocalUser(data: {
name: string;
email: string;
sonumRole: "referent" | "gestionnaire" | "adherent";
password: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier unicité email
const existing = await db.select().from(users).where(eq(users.email, data.email)).limit(1);
if (existing.length > 0) throw new Error("EMAIL_EXISTS");
// openId synthétique pour les comptes locaux
const syntheticOpenId = `local_${nanoid(16)}`;
const passwordHash = await bcrypt.hash(data.password, 12);
const insertResult = await db.insert(users).values({
openId: syntheticOpenId,
name: data.name,
email: data.email,
loginMethod: "local",
sonumRole: data.sonumRole,
cguAccepted: false,
lastSignedIn: new Date(),
});
const userId = Number((insertResult as any)[0]?.insertId ?? 0);
if (!userId) throw new Error("Failed to create user");
await db.insert(localCredentials).values({ userId, passwordHash });
return userId;
}
/** Authentifie un utilisateur par email + mot de passe. Retourne l'utilisateur ou null. */
export async function authenticateLocalUser(email: string, password: string) {
const db = await getDb();
if (!db) return null;
const result = await db
.select({
user: users,
passwordHash: localCredentials.passwordHash,
})
.from(users)
.innerJoin(localCredentials, eq(localCredentials.userId, users.id))
.where(eq(users.email, email))
.limit(1);
if (!result.length) return null;
const { user, passwordHash } = result[0];
const valid = await bcrypt.compare(password, passwordHash);
if (!valid) return null;
// Mettre à jour lastSignedIn
await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, user.id));
return user;
}
/** Vérifie si un utilisateur possède des credentials locaux. */
export async function hasLocalCredentials(userId: number) {
const db = await getDb();
if (!db) return false;
const result = await db.select().from(localCredentials).where(eq(localCredentials.userId, userId)).limit(1);
return result.length > 0;
}
/** Met à jour le mot de passe d'un utilisateur. */
export async function updateLocalPassword(userId: number, newPassword: string) {
const db = await getDb();
if (!db) return;
const passwordHash = await bcrypt.hash(newPassword, 12);
const existing = await db.select().from(localCredentials).where(eq(localCredentials.userId, userId)).limit(1);
if (existing.length > 0) {
await db.update(localCredentials).set({ passwordHash, updatedAt: new Date() }).where(eq(localCredentials.userId, userId));
} else {
await db.insert(localCredentials).values({ userId, passwordHash });
}
}
/** Met à jour les informations d'un utilisateur. */
export async function updateUser(userId: number, data: {
name?: string;
email?: string;
sonumRole?: "referent" | "gestionnaire" | "adherent";
}) {
const db = await getDb();
if (!db) return;
await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, userId));
}
/** Supprime un utilisateur et ses credentials locaux. */
export async function deleteUser(userId: number) {
const db = await getDb();
if (!db) return;
await db.delete(localCredentials).where(eq(localCredentials.userId, userId));
await db.delete(userEtablissements).where(eq(userEtablissements.userId, userId));
await db.delete(users).where(eq(users.id, userId));
}
// ─── Affectations Adhérents ↔ Établissements ─────────────────────────────────
/** Retourne les établissements affectés à un adhérent. */
export async function getEtablissementsByAdherent(userId: number) {
const db = await getDb();
if (!db) return [];
return db
.select({
id: etablissements.id,
finess: etablissements.finess,
nom: etablissements.nom,
region: etablissements.region,
departement: etablissements.departement,
typeActivite: etablissements.typeActivite,
tailleEffectifs: etablissements.tailleEffectifs,
referentId: etablissements.referentId,
visibilite: etablissements.visibilite,
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
})
.from(userEtablissements)
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id))
.where(eq(userEtablissements.userId, userId))
.orderBy(etablissements.nom);
}
/** Retourne les IDs des établissements affectés à un adhérent. */
export async function getAffectationsByUser(userId: number) {
const db = await getDb();
if (!db) return [];
const result = await db
.select({ etablissementId: userEtablissements.etablissementId })
.from(userEtablissements)
.where(eq(userEtablissements.userId, userId));
return result.map((r) => r.etablissementId);
}
/** Affecte un établissement à un adhérent (idempotent). */
export async function assignEtablissementToUser(userId: number, etablissementId: number) {
const db = await getDb();
if (!db) return;
const existing = await db
.select()
.from(userEtablissements)
.where(and(eq(userEtablissements.userId, userId), eq(userEtablissements.etablissementId, etablissementId)))
.limit(1);
if (existing.length === 0) {
await db.insert(userEtablissements).values({ userId, etablissementId });
}
}
/** Retire un établissement d'un adhérent. */
export async function removeEtablissementFromUser(userId: number, etablissementId: number) {
const db = await getDb();
if (!db) return;
await db
.delete(userEtablissements)
.where(and(eq(userEtablissements.userId, userId), eq(userEtablissements.etablissementId, etablissementId)));
}
/** Remplace toutes les affectations d'un adhérent par une nouvelle liste. */
export async function setAffectationsForUser(userId: number, etablissementIds: number[]) {
const db = await getDb();
if (!db) return;
await db.delete(userEtablissements).where(eq(userEtablissements.userId, userId));
if (etablissementIds.length > 0) {
await db.insert(userEtablissements).values(etablissementIds.map((eid) => ({ userId, etablissementId: eid })));
}
}
/** Retourne tous les utilisateurs avec leurs affectations. */
export async function getAllUsersWithAffectations() {
const db = await getDb();
if (!db) return [];
const allUsers = await db.select().from(users).orderBy(users.name);
const allAffectations = await db
.select({
userId: userEtablissements.userId,
etablissementId: userEtablissements.etablissementId,
etablissementNom: etablissements.nom,
})
.from(userEtablissements)
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id));
return allUsers.map((u) => ({
...u,
etablissements: allAffectations
.filter((a) => a.userId === u.id)
.map((a) => ({ id: a.etablissementId, nom: a.etablissementNom })),
hasLocalCredentials: false, // sera enrichi côté router si besoin
}));
}