feat: v8 - skill itinova-user-management (3 profils admin/standard/readonly, logo FEHAP, login/email)
This commit is contained in:
@@ -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 ? (
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="w-full px-2 py-1 text-sm bg-background border border-primary/40 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
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>
|
||||
<span className="font-medium text-foreground">{u.name ?? "—"}</span>
|
||||
<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!",
|
||||
|
||||
@@ -1,87 +1,135 @@
|
||||
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-white">
|
||||
<h1 className="text-5xl font-bold mb-4" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
SONUM
|
||||
</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="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" }}
|
||||
>
|
||||
SONUM
|
||||
</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>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Bienvenue</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2 max-w-xs mx-auto">
|
||||
Cartographie des Solutions Numériques des établissements FEHAP
|
||||
</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>
|
||||
|
||||
<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
|
||||
href={getLoginUrl()}
|
||||
className="group flex items-center gap-4 p-5 bg-primary text-white rounded-2xl shadow-md hover:bg-primary/90 transition-all hover:shadow-lg hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center flex-shrink-0">
|
||||
<ExternalLink size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-semibold text-base">Espace adhérent FEHAP</div>
|
||||
<div className="text-sm text-white/75 mt-0.5">
|
||||
Connexion via votre compte FEHAP existant
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-white/50 group-hover:text-white/80 transition-colors text-lg">→</span>
|
||||
</a>
|
||||
|
||||
{/* Séparateur */}
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-muted-foreground font-medium">ou</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Connexion locale */}
|
||||
<button
|
||||
onClick={() => navigate("/login/local")}
|
||||
className="group w-full flex items-center gap-4 p-5 bg-card border border-border rounded-2xl shadow-sm hover:border-primary/40 hover:shadow-md transition-all hover:-translate-y-0.5 text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<KeyRound size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<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">
|
||||
Identifiant / email et mot de passe
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground group-hover:text-primary transition-colors text-lg">→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Options de connexion */}
|
||||
<div className="space-y-4">
|
||||
{/* Connexion via espace adhérent FEHAP */}
|
||||
<a
|
||||
href={getLoginUrl()}
|
||||
className="group flex items-center gap-4 p-5 bg-primary text-white rounded-2xl shadow-md hover:bg-primary/90 transition-all hover:shadow-lg hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center flex-shrink-0">
|
||||
<ExternalLink size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-semibold text-base">Espace adhérent FEHAP</div>
|
||||
<div className="text-sm text-white/75 mt-0.5">
|
||||
Connexion via votre compte FEHAP existant
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-white/50 group-hover:text-white/80 transition-colors">
|
||||
→
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Séparateur */}
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-muted-foreground font-medium">ou</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Connexion locale */}
|
||||
<button
|
||||
onClick={() => navigate("/login/local")}
|
||||
className="group w-full flex items-center gap-4 p-5 bg-card border border-border rounded-2xl shadow-sm hover:border-primary/40 hover:shadow-md transition-all hover:-translate-y-0.5 text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<KeyRound size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground group-hover:text-primary transition-colors">
|
||||
→
|
||||
</div>
|
||||
</button>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
<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" }}
|
||||
>
|
||||
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
|
||||
<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-white">
|
||||
<h1 className="text-5xl font-bold mb-4" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
SONUM
|
||||
</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>
|
||||
|
||||
{/* Formulaire */}
|
||||
<div className="bg-card rounded-2xl border border-border shadow-sm p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Adresse email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail 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"
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mot de passe */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bouton connexion */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full py-2.5 px-4 bg-primary text-white rounded-lg font-medium text-sm hover:bg-primary/90 transition-colors shadow-sm disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Connexion en cours...
|
||||
</>
|
||||
) : (
|
||||
"Se connecter"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{/* ── 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>
|
||||
|
||||
{/* Liens */}
|
||||
<div className="mt-6 space-y-3 text-center">
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
className="flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mx-auto"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Retour aux options de connexion
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-muted-foreground">ou</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
{/* 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>
|
||||
|
||||
<a
|
||||
href={getLoginUrl()}
|
||||
className="flex items-center justify-center gap-2 text-sm text-primary hover:text-primary/80 font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Se connecter via l'espace adhérent FEHAP
|
||||
</a>
|
||||
<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>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border shadow-sm p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Login ou email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Identifiant ou email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mot de passe */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bouton connexion */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full py-2.5 px-4 bg-primary text-white rounded-lg font-medium text-sm hover:bg-primary/90 transition-colors shadow-sm disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Connexion en cours...
|
||||
</>
|
||||
) : (
|
||||
"Se connecter"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Liens */}
|
||||
<div className="mt-6 space-y-3 text-center">
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
className="flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mx-auto"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Retour aux options de connexion
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-muted-foreground">ou</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={getLoginUrl()}
|
||||
className="flex items-center justify-center gap-2 text-sm text-primary hover:text-primary/80 font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Se connecter via l'espace adhérent FEHAP
|
||||
</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>
|
||||
|
||||
6
drizzle/0003_dry_dragon_man.sql
Normal file
6
drizzle/0003_dry_dragon_man.sql
Normal 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`);
|
||||
820
drizzle/meta/0003_snapshot.json
Normal file
820
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
34
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
41
server/_core/storageProxy.ts
Normal file
41
server/_core/storageProxy.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
68
server/db.ts
68
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<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. */
|
||||
|
||||
@@ -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
15
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éé
|
||||
|
||||
Reference in New Issue
Block a user