+ {/* Logo FEHAP en haut */}
+
+
- {/* 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
+
+
+
+
+ {/* Liens */}
+
+
+
+
+
+
+
+ Se connecter via l'espace adhérent FEHAP
+
+
+
+
+ {/* 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éé