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

@@ -1,14 +1,19 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
createBlocFonctionnel,
assignEtablissementToUser,
authenticateLocalUser,
createDemandeContact,
createBlocFonctionnel,
createEditeur,
createLocalUser,
createSolution,
deleteLogicielEtablissement,
deleteUser,
getAllDemandes,
getAllEtablissements,
getAllUsers,
getAllUsersWithAffectations,
getAffectationsByUser,
getBlocsFonctionnels,
getConsultationCount,
getConsultationsList,
@@ -17,11 +22,16 @@ import {
getDemandesRecuesParEtablissement,
getEditeurs,
getEtablissementById,
getEtablissementsByAdherent,
getEtablissementsByReferent,
getLogicielsByEtablissement,
getSolutions,
recordConsultation,
removeEtablissementFromUser,
repondreDemandeContact,
setAffectationsForUser,
updateLocalPassword,
updateUser,
updateUserCgu,
updateUserSonumRole,
upsertLogicielEtablissement,
@@ -35,6 +45,7 @@ import { notifyOwner } from "./_core/notification";
import { getDb } from "./db";
import { etablissements } from "../drizzle/schema";
import { eq } from "drizzle-orm";
import { sdk } from "./_core/sdk";
// ─── Middleware gestionnaire SONUM ────────────────────────────────────────────
@@ -53,11 +64,41 @@ export const appRouter = router({
// ─── Auth ──────────────────────────────────────────────────────────────────
auth: router({
me: publicProcedure.query((opts) => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
/**
* Connexion locale par email + mot de passe.
* Crée un cookie de session identique à celui de l'OAuth.
*/
loginLocal: publicProcedure
.input(z.object({
email: z.string().email(),
password: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
const user = await authenticateLocalUser(input.email, input.password);
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Email ou mot de passe incorrect" });
}
// Créer un token de session avec l'openId de l'utilisateur local
const sessionToken = await sdk.createSessionToken(user.openId!, {
name: user.name ?? "",
});
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.cookie(COOKIE_NAME, sessionToken, {
...cookieOptions,
maxAge: 1000 * 60 * 60 * 24 * 365, // 1 an
});
return { success: true, user };
}),
}),
// ─── CGU ───────────────────────────────────────────────────────────────────
@@ -108,9 +149,21 @@ export const appRouter = router({
// ─── Établissements ────────────────────────────────────────────────────────
etablissements: router({
mesEtablissements: protectedProcedure.query(({ ctx }) =>
getEtablissementsByReferent(ctx.user.id)
),
/**
* Retourne les établissements selon le rôle :
* - référent : ses établissements
* - adhérent : ses établissements affectés
* - gestionnaire : tous
*/
mesEtablissements: protectedProcedure.query(({ ctx }) => {
if (ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin") {
return getAllEtablissements();
}
if (ctx.user.sonumRole === "adherent") {
return getEtablissementsByAdherent(ctx.user.id);
}
return getEtablissementsByReferent(ctx.user.id);
}),
all: gestionnaireProcedure.query(() => getAllEtablissements()),
@@ -119,7 +172,13 @@ export const appRouter = router({
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.id);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
// Vérifier visibilité
// Adhérent : vérifier qu'il a accès à cet établissement
if (ctx.user.sonumRole === "adherent") {
const affectations = await getAffectationsByUser(ctx.user.id);
if (!affectations.includes(input.id)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (etab.referentId !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
@@ -192,6 +251,11 @@ export const appRouter = router({
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
// Adhérent : vérifier affectation
if (ctx.user.sonumRole === "adherent") {
const affectations = await getAffectationsByUser(ctx.user.id);
if (!affectations.includes(input.etablissementId)) throw new TRPCError({ code: "FORBIDDEN" });
}
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (etab.referentId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" });
}
@@ -284,7 +348,6 @@ export const appRouter = router({
message: input.message,
});
// Notification au propriétaire (gestionnaire SONUM)
await notifyOwner({
title: `Nouvelle demande de contact — ${etab.nom}`,
content: `${ctx.user.name} souhaite contacter le référent de ${etab.nom}.\n\nMessage : ${input.message}`,
@@ -321,17 +384,110 @@ export const appRouter = router({
// ─── Administration ────────────────────────────────────────────────────────
admin: router({
users: gestionnaireProcedure.query(() => getAllUsers()),
/** Liste tous les utilisateurs avec leurs établissements affectés */
users: gestionnaireProcedure.query(() => getAllUsersWithAffectations()),
/** Crée un utilisateur manuellement avec un mot de passe local */
createUser: gestionnaireProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
}))
.mutation(async ({ input }) => {
try {
const userId = await createLocalUser(input);
return { success: true, userId };
} catch (err: any) {
if (err.message === "EMAIL_EXISTS") {
throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec cet email existe déjà" });
}
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: err.message });
}
}),
/** Met à jour les informations d'un utilisateur */
updateUser: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
name: z.string().min(1).optional(),
email: z.string().email().optional(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]).optional(),
}))
.mutation(async ({ input }) => {
const { userId, ...data } = input;
await updateUser(userId, data);
return { success: true };
}),
/** Réinitialise le mot de passe d'un utilisateur local */
resetPassword: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
newPassword: z.string().min(8),
}))
.mutation(async ({ input }) => {
await updateLocalPassword(input.userId, input.newPassword);
return { success: true };
}),
/** Supprime un utilisateur */
deleteUser: gestionnaireProcedure
.input(z.object({ userId: z.number().int() }))
.mutation(async ({ input }) => {
await deleteUser(input.userId);
return { success: true };
}),
/** Ancienne procédure de mise à jour du rôle (rétrocompatibilité) */
updateRole: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
sonumRole: z.enum(["referent", "gestionnaire"]),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
}))
.mutation(async ({ input }) => {
await updateUserSonumRole(input.userId, input.sonumRole);
return { success: true };
}),
/** Retourne les établissements affectés à un utilisateur */
affectations: gestionnaireProcedure
.input(z.object({ userId: z.number().int() }))
.query(({ input }) => getAffectationsByUser(input.userId)),
/** Remplace toutes les affectations d'un adhérent */
setAffectations: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementIds: z.array(z.number().int()),
}))
.mutation(async ({ input }) => {
await setAffectationsForUser(input.userId, input.etablissementIds);
return { success: true };
}),
/** Ajoute un établissement à un utilisateur */
assignEtablissement: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementId: z.number().int(),
}))
.mutation(async ({ input }) => {
await assignEtablissementToUser(input.userId, input.etablissementId);
return { success: true };
}),
/** Retire un établissement d'un utilisateur */
removeEtablissement: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementId: z.number().int(),
}))
.mutation(async ({ input }) => {
await removeEtablissementFromUser(input.userId, input.etablissementId);
return { success: true };
}),
}),
});