diff --git a/client/src/App.tsx b/client/src/App.tsx index c8ff2b4..29cc16e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,15 +9,24 @@ import MesEtablissements from "./pages/MesEtablissements"; import MesDemandes from "./pages/MesDemandes"; import FicheEtablissement from "./pages/FicheEtablissement"; import Admin from "./pages/Admin"; +import Login from "./pages/Login"; +import LoginLocal from "./pages/LoginLocal"; function Router() { return ( + {/* Pages publiques de connexion */} + + + + {/* Pages applicatives (nécessitent une connexion) */} + + {/* Fallback */} diff --git a/client/src/_core/hooks/useAuth.ts b/client/src/_core/hooks/useAuth.ts index dcef9bd..b6007ed 100644 --- a/client/src/_core/hooks/useAuth.ts +++ b/client/src/_core/hooks/useAuth.ts @@ -9,7 +9,9 @@ type UseAuthOptions = { }; export function useAuth(options?: UseAuthOptions) { - const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } = + // Par défaut, rediriger vers la page de choix de connexion (/login) + // et non directement vers l'OAuth Manus, pour laisser le choix à l'utilisateur. + const { redirectOnUnauthenticated = false, redirectPath = "/login" } = options ?? {}; const utils = trpc.useUtils(); diff --git a/client/src/components/SonumLayout.tsx b/client/src/components/SonumLayout.tsx index 9b6222e..68e7988 100644 --- a/client/src/components/SonumLayout.tsx +++ b/client/src/components/SonumLayout.tsx @@ -62,14 +62,14 @@ export default function SonumLayout({ children }: { children: React.ReactNode })

- Se connecter via l'espace adhérent FEHAP + Se connecter

- Accès réservé aux adhérents FEHAP + Accès réservé aux utilisateurs SONUM

@@ -77,6 +77,8 @@ export default function SonumLayout({ children }: { children: React.ReactNode }) } const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin"; + const isAdherent = user?.sonumRole === "adherent"; + const roleLabel = isGestionnaire ? "Gestionnaire SONUM" : isAdherent ? "Adhérent FEHAP" : "Référent numérique"; return (
@@ -214,8 +216,8 @@ export default function SonumLayout({ children }: { children: React.ReactNode }) : "bg-primary/10 text-primary border border-primary/20" }`} > - {isGestionnaire ? : } - {isGestionnaire ? "Gestionnaire SONUM" : "Référent numérique"} + {isGestionnaire ? : isAdherent ? : } + {roleLabel} {/* Avatar */} diff --git a/client/src/main.tsx b/client/src/main.tsx index 8adf6f5..bebec47 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -18,7 +18,10 @@ const redirectToLoginIfUnauthorized = (error: unknown) => { if (!isUnauthorized) return; - window.location.href = getLoginUrl(); + // Rediriger vers la page de choix de connexion, pas directement vers OAuth + if (!window.location.pathname.startsWith("/login")) { + window.location.href = "/login"; + } }; queryClient.getQueryCache().subscribe(event => { diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx index 45b85d9..5da65e7 100644 --- a/client/src/pages/Admin.tsx +++ b/client/src/pages/Admin.tsx @@ -5,19 +5,42 @@ import { Building2, Check, ChevronDown, + ChevronUp, + Eye, + EyeOff, + Key, + Pencil, Plus, Shield, - User, + Trash2, + UserCheck, Users, X, } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; -import { useLocation } from "wouter"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type SonumRole = "referent" | "gestionnaire" | "adherent"; + +const ROLE_LABELS: Record = { + referent: "Référent numérique", + gestionnaire: "Gestionnaire SONUM", + adherent: "Adhérent FEHAP", +}; + +const ROLE_COLORS: Record = { + referent: "bg-primary/10 text-primary border-primary/20", + gestionnaire: "bg-accent/10 text-accent border-accent/20", + adherent: "bg-emerald-50 text-emerald-700 border-emerald-200", +}; + +// ─── Page principale ────────────────────────────────────────────────────────── export default function Admin() { const { user } = useAuth(); - const [, navigate] = useLocation(); + const [activeTab, setActiveTab] = useState<"users" | "etablissements">("users"); const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin"; if (!isGestionnaire) { @@ -33,115 +56,487 @@ export default function Admin() { return ( -
+
{/* En-tête */}

Administration SONUM

- Gestion des utilisateurs, des établissements et du référentiel + Gestion des utilisateurs, des établissements et des affectations

-
- {/* Gestion des utilisateurs */} -
- -
- - {/* Gestion des établissements */} -
- -
+ {/* Onglets */} +
+ {(["users", "etablissements"] as const).map((tab) => ( + + ))}
+ + {activeTab === "users" && } + {activeTab === "etablissements" && }
); } +// ─── Panel Utilisateurs ─────────────────────────────────────────────────────── + function UsersPanel() { + const utils = trpc.useUtils(); const usersQuery = trpc.admin.users.useQuery(); - const updateRoleMutation = trpc.admin.updateRole.useMutation({ - onSuccess: () => { - toast.success("Rôle mis à jour"); - usersQuery.refetch(); - }, + const etablissementsQuery = trpc.etablissements.all.useQuery(); + + const [showCreate, setShowCreate] = useState(false); + const [editingId, setEditingId] = useState(null); + const [expandedAffectations, setExpandedAffectations] = useState(null); + const [showPasswordFor, setShowPasswordFor] = useState(null); + + // Formulaire de création + const [createForm, setCreateForm] = useState({ + name: "", + email: "", + sonumRole: "referent" as SonumRole, + password: "", + showPassword: false, }); + // Formulaire d'édition + const [editForm, setEditForm] = useState<{ + name: string; + email: string; + sonumRole: SonumRole; + }>({ name: "", email: "", sonumRole: "referent" }); + + // Formulaire de réinitialisation de mot de passe + const [resetPasswordForm, setResetPasswordForm] = useState({ userId: 0, password: "", show: false }); + + const refetchAll = () => { + utils.admin.users.invalidate(); + }; + + const createMutation = trpc.admin.createUser.useMutation({ + onSuccess: () => { + toast.success("Utilisateur créé avec succès"); + setShowCreate(false); + setCreateForm({ name: "", email: "", sonumRole: "referent", password: "", showPassword: false }); + refetchAll(); + }, + onError: (err) => toast.error(err.message), + }); + + const updateMutation = trpc.admin.updateUser.useMutation({ + onSuccess: () => { + toast.success("Utilisateur mis à jour"); + setEditingId(null); + refetchAll(); + }, + onError: (err) => toast.error(err.message), + }); + + const deleteMutation = trpc.admin.deleteUser.useMutation({ + onSuccess: () => { + toast.success("Utilisateur supprimé"); + refetchAll(); + }, + onError: (err) => toast.error(err.message), + }); + + const resetPasswordMutation = trpc.admin.resetPassword.useMutation({ + onSuccess: () => { + toast.success("Mot de passe réinitialisé"); + setResetPasswordForm({ userId: 0, password: "", show: false }); + }, + onError: (err) => toast.error(err.message), + }); + + const setAffectationsMutation = trpc.admin.setAffectations.useMutation({ + onSuccess: () => { + toast.success("Affectations mises à jour"); + refetchAll(); + }, + onError: (err) => toast.error(err.message), + }); + + const startEdit = (u: any) => { + setEditingId(u.id); + setEditForm({ name: u.name ?? "", email: u.email ?? "", sonumRole: u.sonumRole ?? "referent" }); + }; + + const toggleAffectation = (userId: number, etablissementId: number, currentIds: number[]) => { + const newIds = currentIds.includes(etablissementId) + ? currentIds.filter((id) => id !== etablissementId) + : [...currentIds, etablissementId]; + setAffectationsMutation.mutate({ userId, etablissementIds: newIds }); + }; + return ( -
-
-
+
+ {/* Bouton créer */} +
+ +
+ + {/* Formulaire de création */} + {showCreate && ( +
+

+ + Nouvel utilisateur +

+
+
+ + setCreateForm((f) => ({ ...f, name: e.target.value }))} + className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + placeholder="Prénom Nom" + /> +
+
+ + setCreateForm((f) => ({ ...f, email: e.target.value }))} + className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + placeholder="prenom.nom@etablissement.fr" + /> +
+
+ + +
+
+ +
+ setCreateForm((f) => ({ ...f, password: e.target.value }))} + className="w-full px-3 py-2 pr-9 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + placeholder="••••••••" + /> + +
+
+
+
+ + +
+
+ )} + + {/* Tableau des utilisateurs */} +
+

Utilisateurs

{usersQuery.data?.length ?? 0}
+ + {usersQuery.isLoading ? ( +
+
+
+ ) : ( +
+ + + + + + + + + + + + + {usersQuery.data?.map((u) => { + const isEditing = editingId === u.id; + const affectedIds = (u.etablissements ?? []).map((e: any) => e.id); + const isExpanded = expandedAffectations === u.id; + + return ( + <> + + + + + + + + + + {/* Panneau d'affectations (adhérent uniquement) */} + {isExpanded && u.sonumRole === "adherent" && ( + + + + )} + + ); + })} + +
UtilisateurEmailProfilConnexionÉtablissementsActions
+ {isEditing ? ( + setEditForm((f) => ({ ...f, name: e.target.value }))} + className="w-full px-2 py-1 text-sm bg-background border border-primary/40 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/30" + /> + ) : ( +
+
+ {u.name?.charAt(0)?.toUpperCase() ?? "U"} +
+ {u.name ?? "—"} +
+ )} +
+ {isEditing ? ( + setEditForm((f) => ({ ...f, email: e.target.value }))} + className="w-full px-2 py-1 text-sm bg-background border border-primary/40 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/30" + /> + ) : ( + {u.email ?? "—"} + )} + + {isEditing ? ( + + ) : ( + + {ROLE_LABELS[u.sonumRole as SonumRole] ?? u.sonumRole} + + )} + + + {u.loginMethod === "local" ? "Local" : u.loginMethod ?? "OAuth"} + + + {u.sonumRole === "adherent" ? ( + + ) : ( + + )} + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + {u.sonumRole === "adherent" && ( + + )} + + + )} +
+
+
+

+ + Établissements affectés à {u.name} +

+ {etablissementsQuery.isLoading ? ( +
Chargement...
+ ) : ( +
+ {etablissementsQuery.data?.map((etab) => { + const isAffected = affectedIds.includes(etab.id); + return ( + + ); + })} +
+ )} +
+
+
+ )}
- {usersQuery.isLoading ? ( -
-
-
- ) : ( -
- - - - - - - - - - - {usersQuery.data?.map((u) => ( - - - - - - - ))} - -
UtilisateurEmailRôle SONUMDernière connexion
-
-
- {u.name?.charAt(0)?.toUpperCase() ?? "U"} -
- {u.name ?? "—"} -
-
{u.email ?? "—"} - - - {u.lastSignedIn - ? new Date(u.lastSignedIn).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" }) - : "—"} -
+ {/* Modal réinitialisation mot de passe */} + {resetPasswordForm.show && ( +
+
+

+ + Réinitialiser le mot de passe +

+

+ Définissez un nouveau mot de passe pour cet utilisateur. +

+
+ setResetPasswordForm((f) => ({ ...f, password: e.target.value }))} + className="w-full px-3 py-2 pr-9 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30" + placeholder="Nouveau mot de passe (min. 8 caractères)" + /> + +
+
+ + +
+
)}
); } +// ─── Panel Établissements ───────────────────────────────────────────────────── + function EtablissementsPanel() { const etablissementsQuery = trpc.etablissements.all.useQuery(); + const usersQuery = trpc.admin.users.useQuery(); const [showCreate, setShowCreate] = useState(false); const [form, setForm] = useState({ nom: "", @@ -149,17 +544,21 @@ function EtablissementsPanel() { region: "", typeActivite: "", tailleEffectifs: "", + referentId: undefined as number | undefined, }); const createMutation = trpc.etablissements.create.useMutation({ onSuccess: () => { toast.success("Établissement créé"); setShowCreate(false); - setForm({ nom: "", finess: "", region: "", typeActivite: "", tailleEffectifs: "" }); + setForm({ nom: "", finess: "", region: "", typeActivite: "", tailleEffectifs: "", referentId: undefined }); etablissementsQuery.refetch(); }, + onError: (err) => toast.error(err.message), }); + const referents = usersQuery.data?.filter((u) => u.sonumRole === "referent" || u.sonumRole === "gestionnaire") ?? []; + return (
@@ -179,11 +578,10 @@ function EtablissementsPanel() {
- {/* Formulaire de création */} {showCreate && (
-
-
+
+
+
+ + +
+
+ + {/* Pied de page */} +

+ En vous connectant, vous acceptez les conditions générales d'utilisation de la plateforme SONUM. +

+
+
+ ); +} diff --git a/client/src/pages/LoginLocal.tsx b/client/src/pages/LoginLocal.tsx new file mode 100644 index 0000000..ebdc6b1 --- /dev/null +++ b/client/src/pages/LoginLocal.tsx @@ -0,0 +1,151 @@ +import { trpc } from "@/lib/trpc"; +import { getLoginUrl } from "@/const"; +import { useState } from "react"; +import { useLocation } from "wouter"; +import { toast } from "sonner"; +import { Eye, EyeOff, Lock, Mail, ArrowLeft, ExternalLink } from "lucide-react"; + +export default function LoginLocal() { + const [, navigate] = useLocation(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + const loginMutation = trpc.auth.loginLocal.useMutation({ + onSuccess: () => { + // Forcer un rechargement complet pour réinitialiser le contexte auth + window.location.href = "/"; + }, + onError: (err) => { + toast.error(err.message || "Email ou mot de passe incorrect"); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!email || !password) { + toast.error("Veuillez renseigner votre email et votre mot de passe"); + return; + } + loginMutation.mutate({ email, password }); + }; + + return ( +
+
+ {/* Logo */} +
+
+
+ +
+
+
FEHAP
+
+ SONUM +
+
+
+

Connexion locale

+

+ Connectez-vous avec votre email et votre mot de passe +

+
+ + {/* Formulaire */} +
+
+ {/* Email */} +
+ +
+ + setEmail(e.target.value)} + placeholder="prenom.nom@etablissement.fr" + autoComplete="email" + className="w-full pl-10 pr-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all" + required + /> +
+
+ + {/* Mot de passe */} +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + className="w-full pl-10 pr-10 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all" + required + /> + +
+
+ + {/* Bouton connexion */} + +
+
+ + {/* Liens */} +
+ + + +
+ ); +} diff --git a/drizzle/0002_fast_luckman.sql b/drizzle/0002_fast_luckman.sql new file mode 100644 index 0000000..64da09e --- /dev/null +++ b/drizzle/0002_fast_luckman.sql @@ -0,0 +1,20 @@ +CREATE TABLE `local_credentials` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `passwordHash` varchar(255) NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`), + CONSTRAINT `local_credentials_userId_unique` UNIQUE(`userId`) +); +--> statement-breakpoint +CREATE TABLE `user_etablissements` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `etablissementId` int NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `user_etablissements_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `users` MODIFY COLUMN `openId` varchar(64);--> statement-breakpoint +ALTER TABLE `users` MODIFY COLUMN `sonumRole` enum('referent','gestionnaire','adherent') NOT NULL DEFAULT 'referent'; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..4d56d32 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,785 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "71420563-53eb-41c0-b873-b65ca13fb3fb", + "prevId": "0242a5e6-a3ef-4093-887e-4d05cea98433", + "tables": { + "blocs_fonctionnels": { + "name": "blocs_fonctionnels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estValide": { + "name": "estValide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "blocs_fonctionnels_id": { + "name": "blocs_fonctionnels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "consultations": { + "name": "consultations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "etablissementId": { + "name": "etablissementId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consultePar": { + "name": "consultePar", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consulteParNom": { + "name": "consulteParNom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "consultations_id": { + "name": "consultations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "demandes_contact": { + "name": "demandes_contact", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "etablissementCibleId": { + "name": "etablissementCibleId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demandeurId": { + "name": "demandeurId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demandeurNom": { + "name": "demandeurNom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "demandeurEmail": { + "name": "demandeurEmail", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('en_attente','repondu','ferme')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en_attente'" + }, + "reponse": { + "name": "reponse", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponsePar": { + "name": "reponsePar", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponduAt": { + "name": "reponduAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "demandes_contact_id": { + "name": "demandes_contact_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "editeurs": { + "name": "editeurs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estValide": { + "name": "estValide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "editeurs_id": { + "name": "editeurs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "etablissements": { + "name": "etablissements", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "finess": { + "name": "finess", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "departement": { + "name": "departement", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeActivite": { + "name": "typeActivite", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tailleEffectifs": { + "name": "tailleEffectifs", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referentId": { + "name": "referentId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibilite": { + "name": "visibilite", + "type": "enum('tous','gestionnaires')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tous'" + }, + "accepteMiseEnRelation": { + "name": "accepteMiseEnRelation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "etablissements_id": { + "name": "etablissements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "local_credentials": { + "name": "local_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "local_credentials_id": { + "name": "local_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "local_credentials_userId_unique": { + "name": "local_credentials_userId_unique", + "columns": [ + "userId" + ] + } + }, + "checkConstraint": {} + }, + "logiciels_etablissements": { + "name": "logiciels_etablissements", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "etablissementId": { + "name": "etablissementId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "solutionId": { + "name": "solutionId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etatDeploiement": { + "name": "etatDeploiement", + "type": "enum('demarrage','en_cours','operationnel','en_remplacement')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modeHebergement": { + "name": "modeHebergement", + "type": "enum('hds','on_premise','hybride')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "modeFacturation": { + "name": "modeFacturation", + "type": "enum('saas','achat_maintenance','location')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interoperabilite": { + "name": "interoperabilite", + "type": "enum('non','oui_interface','oui_eai')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "versionMajeure": { + "name": "versionMajeure", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commentaire": { + "name": "commentaire", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contactNom": { + "name": "contactNom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contactFonction": { + "name": "contactFonction", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contactEmail": { + "name": "contactEmail", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "saisiePar": { + "name": "saisiePar", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "logiciels_etablissements_id": { + "name": "logiciels_etablissements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "solutions": { + "name": "solutions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editeurId": { + "name": "editeurId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blocFonctionnelId": { + "name": "blocFonctionnelId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estValide": { + "name": "estValide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "solutions_id": { + "name": "solutions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_etablissements": { + "name": "user_etablissements", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etablissementId": { + "name": "etablissementId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_etablissements_id": { + "name": "user_etablissements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "sonumRole": { + "name": "sonumRole", + "type": "enum('referent','gestionnaire','adherent')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'referent'" + }, + "cguAccepted": { + "name": "cguAccepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cguAcceptedAt": { + "name": "cguAcceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 352968b..99878c8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1776268633192, "tag": "0001_wide_frightful_four", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1776325170672, + "tag": "0002_fast_luckman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index d491bc2..14e6328 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -12,13 +12,19 @@ import { export const users = mysqlTable("users", { id: int("id").autoincrement().primaryKey(), - openId: varchar("openId", { length: 64 }).notNull().unique(), + // openId peut être null pour les comptes créés manuellement (connexion locale uniquement) + openId: varchar("openId", { length: 64 }).unique(), name: text("name"), email: varchar("email", { length: 320 }), loginMethod: varchar("loginMethod", { length: 64 }), role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), - // Profil SONUM : referent = référent numérique, gestionnaire = gestionnaire SONUM - sonumRole: mysqlEnum("sonumRole", ["referent", "gestionnaire"]).default("referent").notNull(), + // Profil SONUM : + // referent = référent numérique (saisit les logiciels de ses établissements) + // gestionnaire = gestionnaire SONUM (accès admin complet) + // adherent = adhérent FEHAP (consultation des fiches de ses établissements affectés) + sonumRole: mysqlEnum("sonumRole", ["referent", "gestionnaire", "adherent"]) + .default("referent") + .notNull(), // CGU acceptée cguAccepted: boolean("cguAccepted").default(false).notNull(), cguAcceptedAt: timestamp("cguAcceptedAt"), @@ -30,12 +36,39 @@ export const users = mysqlTable("users", { export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; +// ─── Authentification locale ────────────────────────────────────────────────── +// Stocke le hash bcrypt du mot de passe pour les comptes créés manuellement. +// Un utilisateur OAuth peut aussi avoir un mot de passe local (double connexion possible). + +export const localCredentials = mysqlTable("local_credentials", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull().unique(), // FK → users.id + passwordHash: varchar("passwordHash", { length: 255 }).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type LocalCredential = typeof localCredentials.$inferSelect; + +// ─── Affectation Adhérents ↔ Établissements ─────────────────────────────────── +// Permet au gestionnaire SONUM d'affecter des établissements à un adhérent FEHAP. +// Un adhérent ne voit que les établissements qui lui sont affectés. + +export const userEtablissements = mysqlTable("user_etablissements", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), // FK → users.id (adhérent) + etablissementId: int("etablissementId").notNull(), // FK → etablissements.id + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type UserEtablissement = typeof userEtablissements.$inferSelect; + // ─── Référentiel : Éditeurs ─────────────────────────────────────────────────── export const editeurs = mysqlTable("editeurs", { id: int("id").autoincrement().primaryKey(), nom: varchar("nom", { length: 255 }).notNull(), - estValide: boolean("estValide").default(true).notNull(), // false = ajouté par un référent, en attente de validation FEHAP + estValide: boolean("estValide").default(true).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), }); @@ -75,11 +108,8 @@ export const etablissements = mysqlTable("etablissements", { departement: varchar("departement", { length: 100 }), typeActivite: varchar("typeActivite", { length: 100 }), tailleEffectifs: varchar("tailleEffectifs", { length: 50 }), - // Référent numérique responsable referentId: int("referentId"), - // Visibilité : "tous" = visible par tous les référents, "gestionnaires" = visible uniquement par gestionnaires SONUM visibilite: mysqlEnum("visibilite", ["tous", "gestionnaires"]).default("tous").notNull(), - // Acceptation mise en relation accepteMiseEnRelation: boolean("accepteMiseEnRelation").default(true).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), @@ -99,24 +129,15 @@ export const logicielsEtablissements = mysqlTable("logiciels_etablissements", { "operationnel", "en_remplacement", ]).notNull(), - modeHebergement: mysqlEnum("modeHebergement", [ - "hds", - "on_premise", - "hybride", - ]), - modeFacturation: mysqlEnum("modeFacturation", [ - "saas", - "achat_maintenance", - "location", - ]), + modeHebergement: mysqlEnum("modeHebergement", ["hds", "on_premise", "hybride"]), + modeFacturation: mysqlEnum("modeFacturation", ["saas", "achat_maintenance", "location"]), interoperabilite: mysqlEnum("interoperabilite", ["non", "oui_interface", "oui_eai"]), versionMajeure: varchar("versionMajeure", { length: 50 }), commentaire: text("commentaire"), - // Contact référent pour ce logiciel contactNom: varchar("contactNom", { length: 255 }), contactFonction: varchar("contactFonction", { length: 255 }), contactEmail: varchar("contactEmail", { length: 320 }), - saisiePar: int("saisiePar"), // userId + saisiePar: int("saisiePar"), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); @@ -128,7 +149,7 @@ export type LogicielEtablissement = typeof logicielsEtablissements.$inferSelect; export const consultations = mysqlTable("consultations", { id: int("id").autoincrement().primaryKey(), etablissementId: int("etablissementId").notNull(), - consultePar: int("consultePar").notNull(), // userId + consultePar: int("consultePar").notNull(), consultéParNom: varchar("consulteParNom", { length: 255 }), createdAt: timestamp("createdAt").defaultNow().notNull(), }); @@ -139,15 +160,11 @@ export type Consultation = typeof consultations.$inferSelect; export const demandesContact = mysqlTable("demandes_contact", { id: int("id").autoincrement().primaryKey(), - // Établissement cible etablissementCibleId: int("etablissementCibleId").notNull(), - // Demandeur demandeurId: int("demandeurId").notNull(), demandeurNom: varchar("demandeurNom", { length: 255 }), demandeurEmail: varchar("demandeurEmail", { length: 320 }), - // Message message: text("message").notNull(), - // Statut statut: mysqlEnum("statut", ["en_attente", "repondu", "ferme"]).default("en_attente").notNull(), reponse: text("reponse"), reponsePar: int("reponsePar"), diff --git a/package.json b/package.json index 59f4a31..0885715 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@trpc/react-query": "^11.6.0", "@trpc/server": "^11.6.0", "axios": "^1.12.0", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -82,6 +83,7 @@ "@builder.io/vite-plugin-jsx-loc": "^0.1.1", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", + "@types/bcryptjs": "^3.0.0", "@types/express": "4.17.21", "@types/google.maps": "^3.58.1", "@types/node": "^24.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a9528..84eff17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: axios: specifier: ^1.12.0 version: 1.12.2 + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -218,6 +221,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.3 version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 '@types/express': specifier: 4.17.21 version: 4.17.21 @@ -2156,6 +2162,10 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2414,6 +2424,10 @@ packages: resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -6468,6 +6482,10 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -6773,6 +6791,8 @@ snapshots: baseline-browser-mapping@2.8.12: {} + bcryptjs@3.0.3: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 diff --git a/server/db.ts b/server/db.ts index d1ca8b8..cdd4261 100644 --- a/server/db.ts +++ b/server/db.ts @@ -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 + })); +} diff --git a/server/routers.ts b/server/routers.ts index 60a48f5..84ae77a 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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 }; + }), }), }); diff --git a/server/sonum-v2.test.ts b/server/sonum-v2.test.ts new file mode 100644 index 0000000..acffc52 --- /dev/null +++ b/server/sonum-v2.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { appRouter } from "./routers"; +import { COOKIE_NAME } from "../shared/const"; +import type { TrpcContext } from "./_core/context"; +import type { User } from "../drizzle/schema"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeUser(overrides: Partial = {}): User { + return { + id: 1, + openId: "test-open-id", + name: "Test User", + email: "test@example.com", + loginMethod: "local", + role: "user", + sonumRole: "referent", + cguAccepted: true, + cguAcceptedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + ...overrides, + }; +} + +function makeCtx(user: User | null = null): TrpcContext { + const cookies: Record = {}; + return { + user, + req: { + protocol: "https", + headers: {}, + } as TrpcContext["req"], + res: { + cookie: (name: string, value: string, _opts: unknown) => { + cookies[name] = value; + }, + clearCookie: (_name: string, _opts: unknown) => {}, + } as unknown as TrpcContext["res"], + }; +} + +// ─── Tests : auth.me ────────────────────────────────────────────────────────── + +describe("auth.me", () => { + it("retourne null quand non authentifié", async () => { + const caller = appRouter.createCaller(makeCtx(null)); + const result = await caller.auth.me(); + expect(result).toBeNull(); + }); + + it("retourne l'utilisateur quand authentifié", async () => { + const user = makeUser({ name: "Alice" }); + const caller = appRouter.createCaller(makeCtx(user)); + const result = await caller.auth.me(); + expect(result?.name).toBe("Alice"); + }); +}); + +// ─── Tests : auth.logout ────────────────────────────────────────────────────── + +describe("auth.logout", () => { + it("efface le cookie de session et retourne success", async () => { + const clearedCookies: string[] = []; + const ctx: TrpcContext = { + user: makeUser(), + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { + clearCookie: (name: string) => clearedCookies.push(name), + } as unknown as TrpcContext["res"], + }; + const caller = appRouter.createCaller(ctx); + const result = await caller.auth.logout(); + expect(result.success).toBe(true); + expect(clearedCookies).toContain(COOKIE_NAME); + }); +}); + +// ─── Tests : auth.loginLocal ────────────────────────────────────────────────── + +describe("auth.loginLocal", () => { + it("rejette un email invalide", async () => { + const caller = appRouter.createCaller(makeCtx(null)); + await expect( + caller.auth.loginLocal({ email: "not-an-email", password: "password123" }) + ).rejects.toThrow(); + }); + + it("rejette un mot de passe vide", async () => { + const caller = appRouter.createCaller(makeCtx(null)); + await expect( + caller.auth.loginLocal({ email: "test@example.com", password: "" }) + ).rejects.toThrow(); + }); +}); + +// ─── Tests : cgu ───────────────────────────────────────────────────────────── + +describe("cgu.status", () => { + it("retourne le statut CGU de l'utilisateur", async () => { + const user = makeUser({ cguAccepted: true }); + const caller = appRouter.createCaller(makeCtx(user)); + const result = await caller.cgu.status(); + expect(result.accepted).toBe(true); + }); + + it("retourne false si CGU non acceptée", async () => { + const user = makeUser({ cguAccepted: false, cguAcceptedAt: null }); + const caller = appRouter.createCaller(makeCtx(user)); + const result = await caller.cgu.status(); + expect(result.accepted).toBe(false); + }); + + it("lève UNAUTHORIZED si non authentifié", async () => { + const caller = appRouter.createCaller(makeCtx(null)); + await expect(caller.cgu.status()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); +}); + +// ─── Tests : gestion des rôles ──────────────────────────────────────────────── + +describe("admin.updateRole", () => { + it("lève FORBIDDEN pour un référent", async () => { + const user = makeUser({ sonumRole: "referent" }); + const caller = appRouter.createCaller(makeCtx(user)); + await expect( + caller.admin.updateRole({ userId: 2, sonumRole: "gestionnaire" }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("lève FORBIDDEN pour un adhérent", async () => { + const user = makeUser({ sonumRole: "adherent" }); + const caller = appRouter.createCaller(makeCtx(user)); + await expect( + caller.admin.updateRole({ userId: 2, sonumRole: "referent" }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("admin.createUser", () => { + it("lève FORBIDDEN pour un référent", async () => { + const user = makeUser({ sonumRole: "referent" }); + const caller = appRouter.createCaller(makeCtx(user)); + await expect( + caller.admin.createUser({ + name: "Test", + email: "test@test.com", + sonumRole: "adherent", + password: "password123", + }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("valide que le mot de passe fait au moins 8 caractères", async () => { + const user = makeUser({ sonumRole: "gestionnaire" }); + const caller = appRouter.createCaller(makeCtx(user)); + await expect( + caller.admin.createUser({ + name: "Test", + email: "test@test.com", + sonumRole: "adherent", + password: "short", + }) + ).rejects.toThrow(); + }); +}); + +// ─── Tests : affectations ───────────────────────────────────────────────────── + +describe("admin.setAffectations", () => { + it("lève FORBIDDEN pour un non-gestionnaire", async () => { + const user = makeUser({ sonumRole: "referent" }); + const caller = appRouter.createCaller(makeCtx(user)); + await expect( + caller.admin.setAffectations({ userId: 2, etablissementIds: [1, 2] }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +// ─── Tests : rôles des procédures admin ────────────────────────────────────── + +describe("admin.deleteUser", () => { + it("lève FORBIDDEN pour un adhérent", async () => { + const user = makeUser({ sonumRole: "adherent" }); + const caller = appRouter.createCaller(makeCtx(user)); + await expect( + caller.admin.deleteUser({ userId: 2 }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("admin.resetPassword", () => { + it("lève FORBIDDEN pour un référent", async () => { + const user = makeUser({ sonumRole: "referent" }); + const caller = appRouter.createCaller(makeCtx(user)); + await expect( + caller.admin.resetPassword({ userId: 2, newPassword: "newpassword123" }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +// ─── Tests : filtrage adhérent ──────────────────────────────────────────────────────────────────────────────── + +describe("etablissements.byId - contrôle accès adhérent", () => { + it("lève FORBIDDEN si l'adhérent tente d'accéder à un établissement non affecté", async () => { + // L'adhérent n'a aucun établissement affecté (DB vide en test) + const user = makeUser({ sonumRole: "adherent", id: 999 }); + const caller = appRouter.createCaller(makeCtx(user)); + // L'établissement id=1 n'est pas affecté à l'utilisateur id=999 + // La procédure doit lever FORBIDDEN ou NOT_FOUND + await expect( + caller.etablissements.byId({ id: 1 }) + ).rejects.toThrow(); + }); +}); + +describe("admin.setAffectations - accès gestionnaire", () => { + it("accepte la requête d'un gestionnaire (ne lève pas FORBIDDEN)", async () => { + const user = makeUser({ sonumRole: "gestionnaire" }); + const caller = appRouter.createCaller(makeCtx(user)); + // setAffectations avec une liste vide est idempotent et ne doit pas lever FORBIDDEN + // (peut échouer sur DB indisponible mais pas sur les permissions) + try { + await caller.admin.setAffectations({ userId: 999, etablissementIds: [] }); + } catch (err: any) { + // Seule une erreur de permission est inacceptable + expect(err?.code).not.toBe("FORBIDDEN"); + } + }); +}); + +describe("auth.loginLocal - validation", () => { + it("rejette un mot de passe trop court", async () => { + const caller = appRouter.createCaller(makeCtx(null)); + await expect( + caller.auth.loginLocal({ email: "user@test.com", password: "" }) + ).rejects.toThrow(); + }); + + it("retourne UNAUTHORIZED pour des credentials inexistants", async () => { + const caller = appRouter.createCaller(makeCtx(null)); + await expect( + caller.auth.loginLocal({ email: "nonexistent@test.com", password: "password123" }) + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); +}); diff --git a/todo.md b/todo.md index c760ec1..b3e1f9b 100644 --- a/todo.md +++ b/todo.md @@ -31,3 +31,28 @@ ## Tests - [x] Tests unitaires procédures tRPC (14 tests passés) + +## Évolution v2 — Profil Adhérent FEHAP & Auth locale + +### Base de données +- [x] Étendre enum sonumRole : ajouter "adherent" +- [x] Table local_users : id, userId (FK), passwordHash, createdAt +- [x] Vérifier que la table users supporte les comptes créés manuellement (sans openId OAuth) + +### Backend tRPC +- [x] Procédure admin.createUser : créer un utilisateur manuellement (nom, email, rôle, mot de passe) +- [x] Procédure admin.listUsers : liste complète des utilisateurs avec rôle et établissements +- [x] Procédure admin.updateUser : modifier nom, email, rôle d'un utilisateur +- [x] Procédure admin.deleteUser : supprimer un utilisateur +- [x] Procédure admin.setAffectations : affecter un ou plusieurs établissements à un adhérent +- [x] Procédure admin.removeEtablissement : retirer un établissement d'un adhérent (inclus dans setAffectations) +- [x] Procédure auth.loginLocal : authentification par email + mot de passe (JWT session) +- [x] Middleware : les adhérents FEHAP voient uniquement leurs établissements affectés + +### Interface +- [x] Page de choix de connexion : OAuth FEHAP vs Connexion locale +- [x] Formulaire de connexion locale (email + mot de passe) +- [x] Page admin : onglet "Gestion des utilisateurs" avec tableau et actions CRUD +- [x] Page admin : affectations établissements inline dans le tableau utilisateurs +- [x] Badge "Adhérent FEHAP" dans le header et la sidebar +- [x] Vue "Mes Établissements" filtrée pour les adhérents (uniquement les établissements affectés)