From a8b1784e2829d5e13eb5365efbf0c81d35765edb Mon Sep 17 00:00:00 2001 From: Manus Deploy Date: Tue, 21 Apr 2026 06:51:07 -0400 Subject: [PATCH] feat: v8 - skill itinova-user-management (3 profils admin/standard/readonly, logo FEHAP, login/email) --- client/src/pages/Admin.tsx | 89 +++- client/src/pages/Login.tsx | 188 +++++--- client/src/pages/LoginLocal.tsx | 257 +++++----- drizzle/0003_dry_dragon_man.sql | 6 + drizzle/meta/0003_snapshot.json | 820 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 13 +- package.json | 2 + pnpm-lock.yaml | 34 ++ server/_core/index.ts | 3 + server/_core/storageProxy.ts | 41 ++ server/db.ts | 68 ++- server/routers.ts | 32 +- todo.md | 15 + 14 files changed, 1356 insertions(+), 219 deletions(-) create mode 100644 drizzle/0003_dry_dragon_man.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 server/_core/storageProxy.ts diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx index c5aa7c5..5d239f6 100644 --- a/client/src/pages/Admin.tsx +++ b/client/src/pages/Admin.tsx @@ -121,19 +121,27 @@ function UsersPanel() { // Formulaire de création const [createForm, setCreateForm] = useState({ - name: "", + firstName: "", + lastName: "", + login: "", email: "", sonumRole: "referent" as SonumRole, + role: "standard" as "admin" | "standard" | "readonly", + isActive: true, password: "", showPassword: false, }); // Formulaire d'édition const [editForm, setEditForm] = useState<{ - name: string; + firstName: string; + lastName: string; + login: string; email: string; sonumRole: SonumRole; - }>({ name: "", email: "", sonumRole: "referent" }); + role: "admin" | "standard" | "readonly"; + isActive: boolean; + }>({ firstName: "", lastName: "", login: "", email: "", sonumRole: "referent", role: "standard", isActive: true }); // Formulaire de réinitialisation de mot de passe const [resetPasswordForm, setResetPasswordForm] = useState({ userId: 0, password: "", show: false }); @@ -146,7 +154,7 @@ function UsersPanel() { onSuccess: () => { toast.success("Utilisateur créé avec succès"); setShowCreate(false); - setCreateForm({ name: "", email: "", sonumRole: "referent", password: "", showPassword: false }); + setCreateForm({ firstName: "", lastName: "", login: "", email: "", sonumRole: "referent", role: "standard", isActive: true, password: "", showPassword: false }); refetchAll(); }, onError: (err) => toast.error(err.message), @@ -187,7 +195,15 @@ function UsersPanel() { const startEdit = (u: any) => { setEditingId(u.id); - setEditForm({ name: u.name ?? "", email: u.email ?? "", sonumRole: u.sonumRole ?? "referent" }); + setEditForm({ + firstName: u.firstName ?? "", + lastName: u.lastName ?? "", + login: u.login ?? "", + email: u.email ?? "", + sonumRole: u.sonumRole ?? "referent", + role: (u.role ?? "standard") as "admin" | "standard" | "readonly", + isActive: u.isActive !== false, + }); }; const toggleAffectation = (userId: number, etablissementId: number, currentIds: number[]) => { @@ -219,12 +235,30 @@ function UsersPanel() {
- + setCreateForm((f) => ({ ...f, name: e.target.value }))} + value={createForm.firstName} + onChange={(e) => setCreateForm((f) => ({ ...f, firstName: 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" + placeholder="Prénom" + /> +
+
+ + setCreateForm((f) => ({ ...f, lastName: 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="Nom de famille" + /> +
+
+ + setCreateForm((f) => ({ ...f, login: 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="identifiant court (ex: jdupont)" />
@@ -279,8 +313,8 @@ function UsersPanel() { Annuler +
+ +

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

- {/* Options de connexion */} -
- {/* Connexion via espace adhérent FEHAP */} - -
- -
-
-
Espace adhérent FEHAP
-
- Connexion via votre compte FEHAP existant -
-
-
- → -
-
- - {/* Séparateur */} -
-
- ou -
-
- - {/* Connexion locale */} - + {/* Pied de page : powered by Santinova */} +
+ powered by + {SANTINOVA_LOGO_TEXT}
- - {/* 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 index ebdc6b1..8ca23de 100644 --- a/client/src/pages/LoginLocal.tsx +++ b/client/src/pages/LoginLocal.tsx @@ -3,11 +3,14 @@ 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"; +import { Eye, EyeOff, Lock, User, ArrowLeft, ExternalLink } from "lucide-react"; + +const FEHAP_LOGO = "/manus-storage/logoFEHAP_69ddd0ee.PNG"; +const SANTINOVA_LOGO_TEXT = "Santinova Soft"; export default function LoginLocal() { const [, navigate] = useLocation(); - const [email, setEmail] = useState(""); + const [loginOrEmail, setLoginOrEmail] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); @@ -17,133 +20,163 @@ export default function LoginLocal() { window.location.href = "/"; }, onError: (err) => { - toast.error(err.message || "Email ou mot de passe incorrect"); + toast.error(err.message || "Identifiant 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"); + if (!loginOrEmail || !password) { + toast.error("Veuillez renseigner votre identifiant et votre mot de passe"); return; } - loginMutation.mutate({ email, password }); + loginMutation.mutate({ email: loginOrEmail, password }); }; return ( -
-
- {/* Logo */} -
-
-
- -
-
-
FEHAP
-
- SONUM -
-
-
-

Connexion locale

-

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

+ {/* ── Colonne gauche : branding SONUM ── */} +
+
+ FEHAP – Santé Social, Privé Solidaire +
+
+

+ SONUM +

+

+ Cartographie des Solutions Numériques des établissements FEHAP

+
+ © {new Date().getFullYear()} FEHAP — Tous droits réservés +
+
- {/* 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 */} - -
+ {/* ── Colonne droite : formulaire ── */} +
+ {/* Logo FEHAP en haut */} +
+ FEHAP
- {/* Liens */} -
- - -
-
- ou -
+ {/* Formulaire centré */} +
+ {/* Titre mobile */} +
+

+ SONUM +

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

Connexion locale

+

+ Connectez-vous avec votre identifiant (login ou email) et votre mot de passe +

+ +
+
+ {/* Login ou email */} +
+ +
+ + setLoginOrEmail(e.target.value)} + placeholder="jdupont ou prenom.nom@etablissement.fr" + autoComplete="username" + 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 */} +
+ + + + + {/* Pied de page : powered by Santinova */} +
+ powered by + {SANTINOVA_LOGO_TEXT}
diff --git a/drizzle/0003_dry_dragon_man.sql b/drizzle/0003_dry_dragon_man.sql new file mode 100644 index 0000000..b99469e --- /dev/null +++ b/drizzle/0003_dry_dragon_man.sql @@ -0,0 +1,6 @@ +ALTER TABLE `users` MODIFY COLUMN `role` enum('admin','standard','readonly') NOT NULL DEFAULT 'standard';--> statement-breakpoint +ALTER TABLE `users` ADD `login` varchar(255);--> statement-breakpoint +ALTER TABLE `users` ADD `firstName` varchar(100);--> statement-breakpoint +ALTER TABLE `users` ADD `lastName` varchar(100);--> statement-breakpoint +ALTER TABLE `users` ADD `isActive` boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD CONSTRAINT `users_login_unique` UNIQUE(`login`); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..0ba80d1 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,820 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "b7067b67-1519-4e42-aec4-caaebda8138f", + "prevId": "71420563-53eb-41c0-b873-b65ca13fb3fb", + "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 + }, + "login": { + "name": "login", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstName": { + "name": "firstName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','standard','readonly')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "sonumRole": { + "name": "sonumRole", + "type": "enum('referent','gestionnaire','adherent')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'referent'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "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" + ] + }, + "users_login_unique": { + "name": "users_login_unique", + "columns": [ + "login" + ] + } + }, + "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 99878c8..4c14f4b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1776325170672, "tag": "0002_fast_luckman", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1776767429233, + "tag": "0003_dry_dragon_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 14e6328..352d3a2 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -14,10 +14,18 @@ export const users = mysqlTable("users", { id: int("id").autoincrement().primaryKey(), // openId peut être null pour les comptes créés manuellement (connexion locale uniquement) openId: varchar("openId", { length: 64 }).unique(), - name: text("name"), + // Login = email ou identifiant court (ex: adminItinova) + login: varchar("login", { length: 255 }).unique(), email: varchar("email", { length: 320 }), + firstName: varchar("firstName", { length: 100 }), + lastName: varchar("lastName", { length: 100 }), + name: text("name"), loginMethod: varchar("loginMethod", { length: 64 }), - role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), + // Rôle technique (skill itinova-user-management) : + // admin = administrateur (tous droits + gestion utilisateurs + paramétrage) + // standard = utilisateur standard (droits métiers, pas de config) + // readonly = lecture seule + role: mysqlEnum("role", ["admin", "standard", "readonly"]).default("standard").notNull(), // Profil SONUM : // referent = référent numérique (saisit les logiciels de ses établissements) // gestionnaire = gestionnaire SONUM (accès admin complet) @@ -25,6 +33,7 @@ export const users = mysqlTable("users", { sonumRole: mysqlEnum("sonumRole", ["referent", "gestionnaire", "adherent"]) .default("referent") .notNull(), + isActive: boolean("isActive").default(true).notNull(), // CGU acceptée cguAccepted: boolean("cguAccepted").default(false).notNull(), cguAcceptedAt: timestamp("cguAcceptedAt"), diff --git a/package.json b/package.json index 0822326..1f5b72b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", "@trpc/server": "^11.6.0", + "@types/bcrypt": "^6.0.0", "axios": "^1.12.0", + "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98fe72f..cadd8b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,9 +115,15 @@ importers: '@trpc/server': specifier: ^11.6.0 version: 11.6.0(typescript@5.9.3) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 axios: specifier: ^1.12.0 version: 1.12.2 + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -2165,6 +2171,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + '@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. @@ -2431,6 +2440,10 @@ packages: resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} hasBin: true + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + bcryptjs@3.0.3: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} hasBin: true @@ -3649,6 +3662,14 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} @@ -6523,6 +6544,10 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 24.7.0 + '@types/bcryptjs@3.0.0': dependencies: bcryptjs: 3.0.3 @@ -6834,6 +6859,11 @@ snapshots: baseline-browser-mapping@2.8.12: {} + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + bcryptjs@3.0.3: {} body-parser@1.20.3: @@ -8295,6 +8325,10 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + node-addon-api@8.7.0: {} + + node-gyp-build@4.8.4: {} + node-releases@2.0.23: {} normalize-range@0.1.2: {} diff --git a/server/_core/index.ts b/server/_core/index.ts index f472331..20bd865 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -4,6 +4,7 @@ import { createServer } from "http"; import net from "net"; import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { registerOAuthRoutes } from "./oauth"; +import { registerStorageProxy } from "./storageProxy"; import { appRouter } from "../routers"; import { createContext } from "./context"; import { serveStatic, setupVite } from "./vite"; @@ -33,6 +34,8 @@ async function startServer() { // Configure body parser with larger size limit for file uploads app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); + // Storage proxy for /manus-storage/* paths + registerStorageProxy(app); // OAuth callback under /api/oauth/callback registerOAuthRoutes(app); // tRPC API diff --git a/server/_core/storageProxy.ts b/server/_core/storageProxy.ts new file mode 100644 index 0000000..8f9372d --- /dev/null +++ b/server/_core/storageProxy.ts @@ -0,0 +1,41 @@ +import type { Express } from "express"; +import { ENV } from "./env"; +export function registerStorageProxy(app: Express) { + app.get("/manus-storage/*", async (req, res) => { + const key = (req.params as Record)[0]; + if (!key) { + res.status(400).send("Missing storage key"); + return; + } + if (!ENV.forgeApiUrl || !ENV.forgeApiKey) { + res.status(500).send("Storage proxy not configured"); + return; + } + try { + const forgeUrl = new URL( + "v1/storage/presign/get", + ENV.forgeApiUrl.replace(/\/+$/, "") + "/", + ); + forgeUrl.searchParams.set("path", key); + const forgeResp = await fetch(forgeUrl, { + headers: { Authorization: `Bearer ${ENV.forgeApiKey}` }, + }); + if (!forgeResp.ok) { + const body = await forgeResp.text().catch(() => ""); + console.error(`[StorageProxy] forge error: ${forgeResp.status} ${body}`); + res.status(502).send("Storage backend error"); + return; + } + const { url } = (await forgeResp.json()) as { url: string }; + if (!url) { + res.status(502).send("Empty signed URL from backend"); + return; + } + res.set("Cache-Control", "no-store"); + res.redirect(307, url); + } catch (err) { + console.error("[StorageProxy] failed:", err); + res.status(502).send("Storage proxy error"); + } + }); +} diff --git a/server/db.ts b/server/db.ts index 9e90ddf..1aa6a84 100644 --- a/server/db.ts +++ b/server/db.ts @@ -421,9 +421,14 @@ 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; + name?: string; + firstName?: string; + lastName?: string; + login?: string; email: string; sonumRole: "referent" | "gestionnaire" | "adherent"; + role?: "admin" | "standard" | "readonly"; + isActive?: boolean; password: string; }) { const db = await getDb(); @@ -433,16 +438,31 @@ export async function createLocalUser(data: { const existing = await db.select().from(users).where(eq(users.email, data.email)).limit(1); if (existing.length > 0) throw new Error("EMAIL_EXISTS"); + // Vérifier unicité login si fourni + if (data.login) { + const existingLogin = await db.select().from(users).where(eq(users.login, data.login)).limit(1); + if (existingLogin.length > 0) throw new Error("LOGIN_EXISTS"); + } + // openId synthétique pour les comptes locaux const syntheticOpenId = `local_${nanoid(16)}`; const passwordHash = await bcrypt.hash(data.password, 12); + // Dériver name si non fourni + const fullName = data.name ?? + (data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` : data.email); + const insertResult = await db.insert(users).values({ openId: syntheticOpenId, - name: data.name, + login: data.login ?? null, + name: fullName, + firstName: data.firstName ?? null, + lastName: data.lastName ?? null, email: data.email, loginMethod: "local", sonumRole: data.sonumRole, + role: data.role ?? "standard", + isActive: data.isActive !== undefined ? data.isActive : true, cguAccepted: false, lastSignedIn: new Date(), }); @@ -455,24 +475,37 @@ export async function createLocalUser(data: { return userId; } -/** Authentifie un utilisateur par email + mot de passe. Retourne l'utilisateur ou null. */ -export async function authenticateLocalUser(email: string, password: string) { +/** Authentifie un utilisateur par login (email ou login court) + mot de passe. Retourne l'utilisateur ou null. */ +export async function authenticateLocalUser(loginOrEmail: string, password: string) { const db = await getDb(); if (!db) return null; - const result = await db - .select({ - user: users, - passwordHash: localCredentials.passwordHash, - }) + // Chercher par email OU par login court + const byEmail = await db + .select({ user: users, passwordHash: localCredentials.passwordHash }) .from(users) .innerJoin(localCredentials, eq(localCredentials.userId, users.id)) - .where(eq(users.email, email)) + .where(eq(users.email, loginOrEmail)) .limit(1); + let result = byEmail; + if (!result.length) { + const byLogin = await db + .select({ user: users, passwordHash: localCredentials.passwordHash }) + .from(users) + .innerJoin(localCredentials, eq(localCredentials.userId, users.id)) + .where(eq(users.login, loginOrEmail)) + .limit(1); + result = byLogin; + } + if (!result.length) return null; const { user, passwordHash } = result[0]; + + // Vérifier que le compte est actif + if (!user.isActive) return null; + const valid = await bcrypt.compare(password, passwordHash); if (!valid) return null; @@ -506,12 +539,25 @@ export async function updateLocalPassword(userId: number, newPassword: string) { /** Met à jour les informations d'un utilisateur. */ export async function updateUser(userId: number, data: { name?: string; + firstName?: string; + lastName?: string; + login?: string; email?: string; sonumRole?: "referent" | "gestionnaire" | "adherent"; + role?: "admin" | "standard" | "readonly"; + isActive?: boolean; }) { const db = await getDb(); if (!db) return; - await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, userId)); + // Dériver name si firstName/lastName fournis + const updateData: Record = { ...data, updatedAt: new Date() }; + if (data.firstName !== undefined || data.lastName !== undefined) { + const current = await db.select().from(users).where(eq(users.id, userId)).limit(1); + const fn = data.firstName ?? current[0]?.firstName ?? ""; + const ln = data.lastName ?? current[0]?.lastName ?? ""; + if (fn || ln) updateData.name = `${fn} ${ln}`.trim(); + } + await db.update(users).set(updateData as any).where(eq(users.id, userId)); } /** Supprime un utilisateur et ses credentials locaux. */ diff --git a/server/routers.ts b/server/routers.ts index 3b1223b..b430e79 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -65,6 +65,14 @@ const gestionnaireProcedure = protectedProcedure.use(({ ctx, next }) => { return next({ ctx }); }); +/** Bloque les mutations pour les utilisateurs en lecture seule (role === 'readonly') */ +const writeProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.user.role === "readonly") { + throw new TRPCError({ code: "FORBIDDEN", message: "Votre compte est en lecture seule. Contactez un gestionnaire SONUM pour obtenir les droits de modification." }); + } + return next({ ctx }); +}); + // ─── Router principal ───────────────────────────────────────────────────────── export const appRouter = router({ @@ -86,13 +94,14 @@ export const appRouter = router({ */ loginLocal: publicProcedure .input(z.object({ - email: z.string().email(), + // Accepte email ou login court + email: z.string().min(1), 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" }); + throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect" }); } // Créer un token de session avec l'openId de l'utilisateur local @@ -325,12 +334,14 @@ export const appRouter = router({ if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN" }); } + if (ctx.user.role === "readonly") throw new TRPCError({ code: "FORBIDDEN", message: "Compte en lecture seule" }); return upsertLogicielEtablissement({ ...input, saisiePar: ctx.user.id }); }), delete: protectedProcedure .input(z.object({ id: z.number().int(), etablissementId: z.number().int() })) .mutation(async ({ input, ctx }) => { + if (ctx.user.role === "readonly") throw new TRPCError({ code: "FORBIDDEN", message: "Compte en lecture seule" }); const etab = await getEtablissementById(input.etablissementId); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") { @@ -379,7 +390,7 @@ export const appRouter = router({ // ─── Demandes de Contact ─────────────────────────────────────────────────── contact: router({ - envoyer: protectedProcedure + envoyer: writeProcedure .input(z.object({ etablissementCibleId: z.number().int(), message: z.string().min(1), @@ -438,9 +449,13 @@ export const appRouter = router({ /** Crée un utilisateur manuellement avec un mot de passe local */ createUser: gestionnaireProcedure .input(z.object({ - name: z.string().min(1), + firstName: z.string().min(1), + lastName: z.string().min(1), + login: z.string().min(2).optional(), email: z.string().email(), sonumRole: z.enum(["referent", "gestionnaire", "adherent"]), + role: z.enum(["admin", "standard", "readonly"]).default("standard"), + isActive: z.boolean().default(true), password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"), })) .mutation(async ({ input }) => { @@ -451,6 +466,9 @@ export const appRouter = router({ if (err.message === "EMAIL_EXISTS") { throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec cet email existe déjà" }); } + if (err.message === "LOGIN_EXISTS") { + throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec ce login existe déjà" }); + } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: err.message }); } }), @@ -459,9 +477,13 @@ export const appRouter = router({ updateUser: gestionnaireProcedure .input(z.object({ userId: z.number().int(), - name: z.string().min(1).optional(), + firstName: z.string().min(1).optional(), + lastName: z.string().min(1).optional(), + login: z.string().min(2).optional(), email: z.string().email().optional(), sonumRole: z.enum(["referent", "gestionnaire", "adherent"]).optional(), + role: z.enum(["admin", "standard", "readonly"]).optional(), + isActive: z.boolean().optional(), })) .mutation(async ({ input }) => { const { userId, ...data } = input; diff --git a/todo.md b/todo.md index 8729d88..fa20b57 100644 --- a/todo.md +++ b/todo.md @@ -88,3 +88,18 @@ ## Évolution v7 - [x] Vue liste Solutions Logicielles : alternance de couleurs de fond (blanc / bleu clair) à chaque changement de solution pour améliorer la lisibilité + +## Évolution v8 — Skill itinova-user-management + +- [x] Schéma DB : ajout champs firstName, lastName, login, isActive dans table users +- [x] Schéma DB : migration enum role (admin/standard/readonly) +- [x] db.ts : createLocalUser étendu (firstName, lastName, login, role, isActive) +- [x] db.ts : authenticateLocalUser accepte login ou email +- [x] db.ts : updateUser étendu avec nouveaux champs +- [x] routers.ts : loginLocal accepte login ou email (pas seulement email) +- [x] routers.ts : middleware writeProcedure pour bloquer mutations readonly +- [x] routers.ts : updateUser étendu avec firstName, lastName, login, role, isActive +- [x] Admin.tsx : formulaire création/édition avec firstName, lastName, login, role, isActive +- [x] Login.tsx : page de choix avec logo FEHAP +- [x] LoginLocal.tsx : formulaire connexion locale avec logo FEHAP (haut) et Santinova (bas) +- [x] Seed admin : compte admin@sonum.fr / Admin2024! créé