feat: v8 - skill itinova-user-management (3 profils admin/standard/readonly, logo FEHAP, login/email)

This commit is contained in:
Manus Deploy
2026-04-21 06:51:07 -04:00
parent 65e345459c
commit a8b1784e28
14 changed files with 1356 additions and 219 deletions

View File

@@ -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() {
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Nom complet *</label>
<label className="block text-xs font-medium text-foreground mb-1.5">Prénom *</label>
<input
value={createForm.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Nom *</label>
<input
value={createForm.lastName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Login (optionnel)</label>
<input
value={createForm.login}
onChange={(e) => 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)"
/>
</div>
<div>
@@ -279,8 +313,8 @@ function UsersPanel() {
Annuler
</button>
<button
onClick={() => createMutation.mutate(createForm)}
disabled={!createForm.name.trim() || !createForm.email.trim() || createForm.password.length < 8 || createMutation.isPending}
onClick={() => createMutation.mutate({ ...createForm, login: createForm.login || undefined })}
disabled={!createForm.firstName.trim() || !createForm.lastName.trim() || !createForm.email.trim() || createForm.password.length < 8 || createMutation.isPending}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 shadow-sm"
>
<Check size={13} />
@@ -328,17 +362,29 @@ function UsersPanel() {
<tr className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5">
{isEditing ? (
<div className="flex gap-1">
<input
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
value={editForm.firstName}
onChange={(e) => setEditForm((f) => ({ ...f, firstName: 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"
placeholder="Prénom"
/>
<input
value={editForm.lastName}
onChange={(e) => setEditForm((f) => ({ ...f, lastName: 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"
placeholder="Nom"
/>
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary flex-shrink-0">
{u.name?.charAt(0)?.toUpperCase() ?? "U"}
</div>
<div>
<span className="font-medium text-foreground">{u.name ?? "—"}</span>
{u.login && <span className="block text-xs text-muted-foreground">@{u.login}</span>}
</div>
</div>
)}
</td>
@@ -395,7 +441,7 @@ function UsersPanel() {
{isEditing ? (
<>
<button
onClick={() => updateMutation.mutate({ userId: u.id, ...editForm })}
onClick={() => updateMutation.mutate({ userId: u.id, ...editForm, login: editForm.login || undefined })}
disabled={updateMutation.isPending}
className="p-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title="Enregistrer"
@@ -1116,8 +1162,13 @@ function ImportContactsPanel() {
const errs: string[] = [];
for (const row of rows) {
try {
// Décomposer nom en prénom/nom
const parts = (row.nom ?? "").trim().split(" ");
const firstName = parts[0] ?? "Inconnu";
const lastName = parts.slice(1).join(" ") || "";
await createUserMutation.mutateAsync({
name: row.nom,
firstName,
lastName: lastName || firstName,
email: row.email,
sonumRole: row.sonumRole,
password: row.password ?? "Sonum2024!",

View File

@@ -1,36 +1,82 @@
import { getLoginUrl } from "@/const";
import { useLocation } from "wouter";
import { Building2, KeyRound, ExternalLink } from "lucide-react";
import { KeyRound, ExternalLink } from "lucide-react";
const FEHAP_LOGO = "/manus-storage/logoFEHAP_69ddd0ee.PNG";
const SANTINOVA_LOGO_TEXT = "Santinova Soft";
export default function Login() {
const [, navigate] = useLocation();
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-md">
{/* En-tête */}
<div className="text-center mb-10">
<div className="inline-flex items-center gap-3 mb-5">
<div className="w-14 h-14 rounded-2xl bg-primary flex items-center justify-center shadow-lg">
<Building2 size={26} className="text-white" />
<div className="min-h-screen flex bg-background">
{/* ── Colonne gauche : branding SONUM ── */}
<div className="hidden lg:flex flex-col justify-between w-1/2 bg-primary px-14 py-12">
<div>
{/* Logo FEHAP */}
<img
src={FEHAP_LOGO}
alt="FEHAP Santé Social, Privé Solidaire"
className="h-16 object-contain bg-white rounded-xl px-3 py-2 shadow"
/>
</div>
<div className="text-left">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">FEHAP</div>
<div
className="text-3xl font-bold text-primary leading-tight"
style={{ fontFamily: "'Playfair Display', serif" }}
>
<div className="text-white">
<h1 className="text-5xl font-bold mb-4" style={{ fontFamily: "'Playfair Display', serif" }}>
SONUM
</div>
</div>
</div>
<h1 className="text-2xl font-semibold text-foreground">Bienvenue</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xs mx-auto">
</h1>
<p className="text-lg text-white/80 max-w-sm leading-relaxed">
Cartographie des Solutions Numériques des établissements FEHAP
</p>
<div className="mt-8 space-y-3 text-sm text-white/70">
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-white/50" />
Référencement des logiciels métiers
</div>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-white/50" />
Cartographie par établissement et région
</div>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-white/50" />
Mise en relation entre adhérents
</div>
</div>
</div>
<div className="text-white/40 text-xs">
© {new Date().getFullYear()} FEHAP Tous droits réservés
</div>
</div>
{/* ── Colonne droite : formulaire de connexion ── */}
<div className="flex flex-col justify-between flex-1 px-8 py-12 lg:px-16">
{/* Logo FEHAP en haut (mobile + desktop) */}
<div className="flex justify-center lg:justify-end">
<img
src={FEHAP_LOGO}
alt="FEHAP"
className="h-12 object-contain bg-white rounded-lg px-2 py-1 shadow-sm border border-border"
/>
</div>
{/* Formulaire centré */}
<div className="w-full max-w-sm mx-auto">
{/* Titre mobile */}
<div className="text-center mb-8 lg:hidden">
<h1 className="text-3xl font-bold text-primary" style={{ fontFamily: "'Playfair Display', serif" }}>
SONUM
</h1>
<p className="text-sm text-muted-foreground mt-1">
Cartographie des Solutions Numériques
</p>
</div>
{/* Options de connexion */}
<h2 className="text-2xl font-semibold text-foreground mb-2">Connexion</h2>
<p className="text-sm text-muted-foreground mb-8">
Choisissez votre mode d'authentification
</p>
<div className="space-y-4">
{/* Connexion via espace adhérent FEHAP */}
<a
@@ -46,9 +92,7 @@ export default function Login() {
Connexion via votre compte FEHAP existant
</div>
</div>
<div className="text-white/50 group-hover:text-white/80 transition-colors">
</div>
<span className="text-white/50 group-hover:text-white/80 transition-colors text-lg">→</span>
</a>
{/* Séparateur */}
@@ -69,20 +113,24 @@ export default function Login() {
<div className="flex-1">
<div className="font-semibold text-base text-foreground">Connexion locale</div>
<div className="text-sm text-muted-foreground mt-0.5">
Email et mot de passe fournis par un gestionnaire SONUM
Identifiant / email et mot de passe
</div>
</div>
<div className="text-muted-foreground group-hover:text-primary transition-colors">
</div>
<span className="text-muted-foreground group-hover:text-primary transition-colors text-lg">→</span>
</button>
</div>
{/* Pied de page */}
<p className="text-center text-xs text-muted-foreground mt-8">
En vous connectant, vous acceptez les conditions générales d'utilisation de la plateforme SONUM.
</p>
</div>
{/* Pied de page : powered by Santinova */}
<div className="flex justify-center lg:justify-end items-center gap-2 mt-8">
<span className="text-xs text-muted-foreground">powered by</span>
<span className="text-xs font-semibold text-foreground/70">{SANTINOVA_LOGO_TEXT}</span>
</div>
</div>
</div>
);
}

View File

@@ -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,60 +20,83 @@ 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 (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-primary flex items-center justify-center shadow-md">
<Lock size={22} className="text-white" />
<div className="min-h-screen flex bg-background">
{/* ── Colonne gauche : branding SONUM ── */}
<div className="hidden lg:flex flex-col justify-between w-1/2 bg-primary px-14 py-12">
<div>
<img
src={FEHAP_LOGO}
alt="FEHAP Santé Social, Privé Solidaire"
className="h-16 object-contain bg-white rounded-xl px-3 py-2 shadow"
/>
</div>
<div className="text-left">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">FEHAP</div>
<div
className="text-2xl font-bold text-primary leading-tight"
style={{ fontFamily: "'Playfair Display', serif" }}
>
<div className="text-white">
<h1 className="text-5xl font-bold mb-4" style={{ fontFamily: "'Playfair Display', serif" }}>
SONUM
</div>
</div>
</div>
<h1 className="text-xl font-semibold text-foreground">Connexion locale</h1>
<p className="text-sm text-muted-foreground mt-1">
Connectez-vous avec votre email et votre mot de passe
</h1>
<p className="text-lg text-white/80 max-w-sm leading-relaxed">
Cartographie des Solutions Numériques des établissements FEHAP
</p>
</div>
<div className="text-white/40 text-xs">
© {new Date().getFullYear()} FEHAP Tous droits réservés
</div>
</div>
{/* ── Colonne droite : formulaire ── */}
<div className="flex flex-col justify-between flex-1 px-8 py-12 lg:px-16">
{/* Logo FEHAP en haut */}
<div className="flex justify-center lg:justify-end">
<img
src={FEHAP_LOGO}
alt="FEHAP"
className="h-12 object-contain bg-white rounded-lg px-2 py-1 shadow-sm border border-border"
/>
</div>
{/* Formulaire centré */}
<div className="w-full max-w-sm mx-auto">
{/* Titre mobile */}
<div className="text-center mb-8 lg:hidden">
<h1 className="text-3xl font-bold text-primary" style={{ fontFamily: "'Playfair Display', serif" }}>
SONUM
</h1>
</div>
<h2 className="text-2xl font-semibold text-foreground mb-2">Connexion locale</h2>
<p className="text-sm text-muted-foreground mb-8">
Connectez-vous avec votre identifiant (login ou email) et votre mot de passe
</p>
{/* Formulaire */}
<div className="bg-card rounded-2xl border border-border shadow-sm p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email */}
{/* Login ou email */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
Adresse email
Identifiant ou email
</label>
<div className="relative">
<Mail size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<User size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="prenom.nom@etablissement.fr"
autoComplete="email"
type="text"
value={loginOrEmail}
onChange={(e) => 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
/>
@@ -146,6 +172,13 @@ export default function LoginLocal() {
</a>
</div>
</div>
{/* Pied de page : powered by Santinova */}
<div className="flex justify-center lg:justify-end items-center gap-2 mt-8">
<span className="text-xs text-muted-foreground">powered by</span>
<span className="text-xs font-semibold text-foreground/70">{SANTINOVA_LOGO_TEXT}</span>
</div>
</div>
</div>
);
}

View File

@@ -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`);

View File

@@ -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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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"),

View File

@@ -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",

34
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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

View File

@@ -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<string, string>)[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");
}
});
}

View File

@@ -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<string, unknown> = { ...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. */

View File

@@ -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;

15
todo.md
View File

@@ -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éé