Checkpoint: Évolution v2 complète : nouveau profil Adhérent FEHAP, connexion locale par email/mot de passe (bcrypt), création manuelle d'utilisateurs par les gestionnaires, affectation d'établissements aux adhérents, page de choix de connexion (/login), refonte de la page Admin avec CRUD complet. 33 tests Vitest passés, zéro erreur TypeScript.

This commit is contained in:
Manus
2026-04-16 04:02:22 -04:00
parent 522d2e3c87
commit a58296774f
17 changed files with 2321 additions and 146 deletions

View File

@@ -9,15 +9,24 @@ import MesEtablissements from "./pages/MesEtablissements";
import MesDemandes from "./pages/MesDemandes";
import FicheEtablissement from "./pages/FicheEtablissement";
import Admin from "./pages/Admin";
import Login from "./pages/Login";
import LoginLocal from "./pages/LoginLocal";
function Router() {
return (
<Switch>
{/* Pages publiques de connexion */}
<Route path="/login" component={Login} />
<Route path="/login/local" component={LoginLocal} />
{/* Pages applicatives (nécessitent une connexion) */}
<Route path="/" component={Home} />
<Route path="/mes-etablissements" component={MesEtablissements} />
<Route path="/mes-demandes" component={MesDemandes} />
<Route path="/etablissement/:id" component={FicheEtablissement} />
<Route path="/admin" component={Admin} />
{/* Fallback */}
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>

View File

@@ -9,7 +9,9 @@ type UseAuthOptions = {
};
export function useAuth(options?: UseAuthOptions) {
const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } =
// Par défaut, rediriger vers la page de choix de connexion (/login)
// et non directement vers l'OAuth Manus, pour laisser le choix à l'utilisateur.
const { redirectOnUnauthenticated = false, redirectPath = "/login" } =
options ?? {};
const utils = trpc.useUtils();

View File

@@ -62,14 +62,14 @@ export default function SonumLayout({ children }: { children: React.ReactNode })
</p>
</div>
<a
href={getLoginUrl()}
href="/login"
className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
Se connecter via l'espace adhérent FEHAP
Se connecter
<ExternalLink size={16} />
</a>
<p className="mt-4 text-xs text-muted-foreground">
Accès réservé aux adhérents FEHAP
Accès réservé aux utilisateurs SONUM
</p>
</div>
</div>
@@ -77,6 +77,8 @@ export default function SonumLayout({ children }: { children: React.ReactNode })
}
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const isAdherent = user?.sonumRole === "adherent";
const roleLabel = isGestionnaire ? "Gestionnaire SONUM" : isAdherent ? "Adhérent FEHAP" : "Référent numérique";
return (
<div className="min-h-screen flex bg-background">
@@ -214,8 +216,8 @@ export default function SonumLayout({ children }: { children: React.ReactNode })
: "bg-primary/10 text-primary border border-primary/20"
}`}
>
{isGestionnaire ? <Shield size={11} /> : <Users size={11} />}
{isGestionnaire ? "Gestionnaire SONUM" : "Référent numérique"}
{isGestionnaire ? <Shield size={11} /> : isAdherent ? <Building2 size={11} /> : <Users size={11} />}
{roleLabel}
</span>
{/* Avatar */}

View File

@@ -18,7 +18,10 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
if (!isUnauthorized) return;
window.location.href = getLoginUrl();
// Rediriger vers la page de choix de connexion, pas directement vers OAuth
if (!window.location.pathname.startsWith("/login")) {
window.location.href = "/login";
}
};
queryClient.getQueryCache().subscribe(event => {

View File

@@ -5,19 +5,42 @@ import {
Building2,
Check,
ChevronDown,
ChevronUp,
Eye,
EyeOff,
Key,
Pencil,
Plus,
Shield,
User,
Trash2,
UserCheck,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useLocation } from "wouter";
// ─── Types ────────────────────────────────────────────────────────────────────
type SonumRole = "referent" | "gestionnaire" | "adherent";
const ROLE_LABELS: Record<SonumRole, string> = {
referent: "Référent numérique",
gestionnaire: "Gestionnaire SONUM",
adherent: "Adhérent FEHAP",
};
const ROLE_COLORS: Record<SonumRole, string> = {
referent: "bg-primary/10 text-primary border-primary/20",
gestionnaire: "bg-accent/10 text-accent border-accent/20",
adherent: "bg-emerald-50 text-emerald-700 border-emerald-200",
};
// ─── Page principale ──────────────────────────────────────────────────────────
export default function Admin() {
const { user } = useAuth();
const [, navigate] = useLocation();
const [activeTab, setActiveTab] = useState<"users" | "etablissements">("users");
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
if (!isGestionnaire) {
@@ -33,115 +56,487 @@ export default function Admin() {
return (
<SonumLayout>
<div className="p-6 lg:p-8 max-w-6xl mx-auto">
<div className="p-6 lg:p-8 max-w-7xl mx-auto">
{/* En-tête */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground mb-1">Administration SONUM</h1>
<p className="text-muted-foreground text-sm">
Gestion des utilisateurs, des établissements et du référentiel
Gestion des utilisateurs, des établissements et des affectations
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Gestion des utilisateurs */}
<div className="lg:col-span-2">
<UsersPanel />
</div>
{/* Gestion des établissements */}
<div className="lg:col-span-2">
<EtablissementsPanel />
</div>
{/* Onglets */}
<div className="flex gap-1 p-1 bg-muted rounded-xl mb-6 w-fit">
{(["users", "etablissements"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === tab
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab === "users" ? <Users size={15} /> : <Building2 size={15} />}
{tab === "users" ? "Utilisateurs" : "Établissements"}
</button>
))}
</div>
{activeTab === "users" && <UsersPanel />}
{activeTab === "etablissements" && <EtablissementsPanel />}
</div>
</SonumLayout>
);
}
// ─── Panel Utilisateurs ───────────────────────────────────────────────────────
function UsersPanel() {
const utils = trpc.useUtils();
const usersQuery = trpc.admin.users.useQuery();
const updateRoleMutation = trpc.admin.updateRole.useMutation({
onSuccess: () => {
toast.success("Rôle mis à jour");
usersQuery.refetch();
},
const etablissementsQuery = trpc.etablissements.all.useQuery();
const [showCreate, setShowCreate] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [expandedAffectations, setExpandedAffectations] = useState<number | null>(null);
const [showPasswordFor, setShowPasswordFor] = useState<number | null>(null);
// Formulaire de création
const [createForm, setCreateForm] = useState({
name: "",
email: "",
sonumRole: "referent" as SonumRole,
password: "",
showPassword: false,
});
// Formulaire d'édition
const [editForm, setEditForm] = useState<{
name: string;
email: string;
sonumRole: SonumRole;
}>({ name: "", email: "", sonumRole: "referent" });
// Formulaire de réinitialisation de mot de passe
const [resetPasswordForm, setResetPasswordForm] = useState({ userId: 0, password: "", show: false });
const refetchAll = () => {
utils.admin.users.invalidate();
};
const createMutation = trpc.admin.createUser.useMutation({
onSuccess: () => {
toast.success("Utilisateur créé avec succès");
setShowCreate(false);
setCreateForm({ name: "", email: "", sonumRole: "referent", password: "", showPassword: false });
refetchAll();
},
onError: (err) => toast.error(err.message),
});
const updateMutation = trpc.admin.updateUser.useMutation({
onSuccess: () => {
toast.success("Utilisateur mis à jour");
setEditingId(null);
refetchAll();
},
onError: (err) => toast.error(err.message),
});
const deleteMutation = trpc.admin.deleteUser.useMutation({
onSuccess: () => {
toast.success("Utilisateur supprimé");
refetchAll();
},
onError: (err) => toast.error(err.message),
});
const resetPasswordMutation = trpc.admin.resetPassword.useMutation({
onSuccess: () => {
toast.success("Mot de passe réinitialisé");
setResetPasswordForm({ userId: 0, password: "", show: false });
},
onError: (err) => toast.error(err.message),
});
const setAffectationsMutation = trpc.admin.setAffectations.useMutation({
onSuccess: () => {
toast.success("Affectations mises à jour");
refetchAll();
},
onError: (err) => toast.error(err.message),
});
const startEdit = (u: any) => {
setEditingId(u.id);
setEditForm({ name: u.name ?? "", email: u.email ?? "", sonumRole: u.sonumRole ?? "referent" });
};
const toggleAffectation = (userId: number, etablissementId: number, currentIds: number[]) => {
const newIds = currentIds.includes(etablissementId)
? currentIds.filter((id) => id !== etablissementId)
: [...currentIds, etablissementId];
setAffectationsMutation.mutate({ userId, etablissementIds: newIds });
};
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center gap-2">
<div className="space-y-6">
{/* Bouton créer */}
<div className="flex justify-end">
<button
onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors shadow-sm"
>
<Plus size={15} />
Créer un utilisateur
</button>
</div>
{/* Formulaire de création */}
{showCreate && (
<div className="bg-card border border-primary/20 rounded-xl p-6 shadow-sm">
<h3 className="font-semibold text-foreground mb-4 flex items-center gap-2">
<UserCheck size={16} className="text-primary" />
Nouvel utilisateur
</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>
<input
value={createForm.name}
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Prénom Nom"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Email *</label>
<input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="prenom.nom@etablissement.fr"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">Profil SONUM *</label>
<select
value={createForm.sonumRole}
onChange={(e) => setCreateForm((f) => ({ ...f, sonumRole: e.target.value as SonumRole }))}
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"
>
<option value="referent">Référent numérique</option>
<option value="gestionnaire">Gestionnaire SONUM</option>
<option value="adherent">Adhérent FEHAP</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">
Mot de passe * <span className="text-muted-foreground font-normal">(min. 8 caractères)</span>
</label>
<div className="relative">
<input
type={createForm.showPassword ? "text" : "password"}
value={createForm.password}
onChange={(e) => setCreateForm((f) => ({ ...f, password: e.target.value }))}
className="w-full px-3 py-2 pr-9 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setCreateForm((f) => ({ ...f, showPassword: !f.showPassword }))}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{createForm.showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowCreate(false)}
className="px-3 py-1.5 text-xs font-medium border border-border rounded-lg text-foreground hover:bg-muted"
>
Annuler
</button>
<button
onClick={() => createMutation.mutate(createForm)}
disabled={!createForm.name.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} />
{createMutation.isPending ? "Création..." : "Créer l'utilisateur"}
</button>
</div>
</div>
)}
{/* Tableau des utilisateurs */}
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<Users size={18} className="text-primary" />
<h2 className="font-semibold text-foreground">Utilisateurs</h2>
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{usersQuery.data?.length ?? 0}
</span>
</div>
{usersQuery.isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/20">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Utilisateur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Email</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Profil</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Connexion</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Établissements</th>
<th className="text-right px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{usersQuery.data?.map((u) => {
const isEditing = editingId === u.id;
const affectedIds = (u.etablissements ?? []).map((e: any) => e.id);
const isExpanded = expandedAffectations === u.id;
return (
<>
<tr key={u.id} 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 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>
)}
</td>
<td className="px-4 py-3.5 hidden md:table-cell">
{isEditing ? (
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
className="w-full px-2 py-1 text-sm bg-background border border-primary/40 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/30"
/>
) : (
<span className="text-muted-foreground text-xs">{u.email ?? "—"}</span>
)}
</td>
<td className="px-4 py-3.5">
{isEditing ? (
<select
value={editForm.sonumRole}
onChange={(e) => setEditForm((f) => ({ ...f, sonumRole: e.target.value as SonumRole }))}
className="text-xs px-2 py-1.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary/30"
>
<option value="referent">Référent numérique</option>
<option value="gestionnaire">Gestionnaire SONUM</option>
<option value="adherent">Adhérent FEHAP</option>
</select>
) : (
<span className={`text-xs px-2.5 py-1 rounded-full border font-medium ${ROLE_COLORS[u.sonumRole as SonumRole] ?? ROLE_COLORS.referent}`}>
{ROLE_LABELS[u.sonumRole as SonumRole] ?? u.sonumRole}
</span>
)}
</td>
<td className="px-4 py-3.5 hidden lg:table-cell text-muted-foreground text-xs">
<span className={`text-xs px-2 py-0.5 rounded border ${u.loginMethod === "local" ? "bg-amber-50 text-amber-700 border-amber-200" : "bg-blue-50 text-blue-700 border-blue-200"}`}>
{u.loginMethod === "local" ? "Local" : u.loginMethod ?? "OAuth"}
</span>
</td>
<td className="px-4 py-3.5 hidden xl:table-cell">
{u.sonumRole === "adherent" ? (
<button
onClick={() => setExpandedAffectations(isExpanded ? null : u.id)}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium transition-colors"
>
<Building2 size={12} />
{affectedIds.length} établissement{affectedIds.length !== 1 ? "s" : ""}
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
) : (
<span className="text-xs text-muted-foreground italic"></span>
)}
</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-end gap-1">
{isEditing ? (
<>
<button
onClick={() => updateMutation.mutate({ userId: u.id, ...editForm })}
disabled={updateMutation.isPending}
className="p-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title="Enregistrer"
>
<Check size={14} />
</button>
<button
onClick={() => setEditingId(null)}
className="p-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
title="Annuler"
>
<X size={14} />
</button>
</>
) : (
<>
<button
onClick={() => startEdit(u)}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() => setResetPasswordForm({ userId: u.id, password: "", show: true })}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-amber-50 hover:text-amber-600 transition-colors"
title="Réinitialiser le mot de passe"
>
<Key size={14} />
</button>
{u.sonumRole === "adherent" && (
<button
onClick={() => setExpandedAffectations(isExpanded ? null : u.id)}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-emerald-50 hover:text-emerald-600 transition-colors xl:hidden"
title="Gérer les affectations"
>
<Building2 size={14} />
</button>
)}
<button
onClick={() => {
if (confirm(`Supprimer l'utilisateur "${u.name}" ?`)) {
deleteMutation.mutate({ userId: u.id });
}
}}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-red-50 hover:text-red-500 transition-colors"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</td>
</tr>
{/* Panneau d'affectations (adhérent uniquement) */}
{isExpanded && u.sonumRole === "adherent" && (
<tr key={`aff-${u.id}`}>
<td colSpan={6} className="px-5 py-4 bg-emerald-50/50 border-b border-emerald-100">
<div className="max-w-2xl">
<p className="text-xs font-semibold text-emerald-800 mb-3 flex items-center gap-1.5">
<Building2 size={13} />
Établissements affectés à {u.name}
</p>
{etablissementsQuery.isLoading ? (
<div className="text-xs text-muted-foreground">Chargement...</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{etablissementsQuery.data?.map((etab) => {
const isAffected = affectedIds.includes(etab.id);
return (
<button
key={etab.id}
onClick={() => toggleAffectation(u.id, etab.id, affectedIds)}
disabled={setAffectationsMutation.isPending}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-xs font-medium transition-all text-left ${
isAffected
? "bg-emerald-100 border-emerald-300 text-emerald-800"
: "bg-white border-border text-muted-foreground hover:border-emerald-200 hover:text-foreground"
}`}
>
<div className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${isAffected ? "bg-emerald-600" : "bg-muted border border-border"}`}>
{isAffected && <Check size={10} className="text-white" />}
</div>
<span className="truncate">{etab.nom}</span>
</button>
);
})}
</div>
)}
</div>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
)}
</div>
{usersQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/20">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Utilisateur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Email</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Rôle SONUM</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Dernière connexion</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{usersQuery.data?.map((u) => (
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3.5">
<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">
{u.name?.charAt(0)?.toUpperCase() ?? "U"}
</div>
<span className="font-medium text-foreground">{u.name ?? "—"}</span>
</div>
</td>
<td className="px-4 py-3.5 hidden md:table-cell text-muted-foreground text-xs">{u.email ?? "—"}</td>
<td className="px-4 py-3.5">
<select
value={u.sonumRole ?? "referent"}
onChange={(e) =>
updateRoleMutation.mutate({
userId: u.id,
sonumRole: e.target.value as "referent" | "gestionnaire",
})
}
className={`text-xs px-2.5 py-1.5 rounded-lg border font-medium focus:outline-none focus:ring-2 focus:ring-primary/30 transition-all ${
u.sonumRole === "gestionnaire"
? "bg-accent/10 text-accent border-accent/20"
: "bg-primary/10 text-primary border-primary/20"
}`}
>
<option value="referent">Référent numérique</option>
<option value="gestionnaire">Gestionnaire SONUM</option>
</select>
</td>
<td className="px-4 py-3.5 hidden lg:table-cell text-muted-foreground text-xs">
{u.lastSignedIn
? new Date(u.lastSignedIn).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })
: "—"}
</td>
</tr>
))}
</tbody>
</table>
{/* Modal réinitialisation mot de passe */}
{resetPasswordForm.show && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-2xl border border-border shadow-xl p-6 w-full max-w-sm">
<h3 className="font-semibold text-foreground mb-1 flex items-center gap-2">
<Key size={16} className="text-amber-500" />
Réinitialiser le mot de passe
</h3>
<p className="text-xs text-muted-foreground mb-4">
Définissez un nouveau mot de passe pour cet utilisateur.
</p>
<div className="relative mb-4">
<input
type={showPasswordFor === resetPasswordForm.userId ? "text" : "password"}
value={resetPasswordForm.password}
onChange={(e) => setResetPasswordForm((f) => ({ ...f, password: e.target.value }))}
className="w-full px-3 py-2 pr-9 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Nouveau mot de passe (min. 8 caractères)"
/>
<button
type="button"
onClick={() => setShowPasswordFor(showPasswordFor === resetPasswordForm.userId ? null : resetPasswordForm.userId)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPasswordFor === resetPasswordForm.userId ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => setResetPasswordForm({ userId: 0, password: "", show: false })}
className="flex-1 px-3 py-2 text-sm font-medium border border-border rounded-lg text-foreground hover:bg-muted"
>
Annuler
</button>
<button
onClick={() => resetPasswordMutation.mutate({ userId: resetPasswordForm.userId, newPassword: resetPasswordForm.password })}
disabled={resetPasswordForm.password.length < 8 || resetPasswordMutation.isPending}
className="flex-1 px-3 py-2 text-sm font-medium bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
{resetPasswordMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Panel Établissements ─────────────────────────────────────────────────────
function EtablissementsPanel() {
const etablissementsQuery = trpc.etablissements.all.useQuery();
const usersQuery = trpc.admin.users.useQuery();
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({
nom: "",
@@ -149,17 +544,21 @@ function EtablissementsPanel() {
region: "",
typeActivite: "",
tailleEffectifs: "",
referentId: undefined as number | undefined,
});
const createMutation = trpc.etablissements.create.useMutation({
onSuccess: () => {
toast.success("Établissement créé");
setShowCreate(false);
setForm({ nom: "", finess: "", region: "", typeActivite: "", tailleEffectifs: "" });
setForm({ nom: "", finess: "", region: "", typeActivite: "", tailleEffectifs: "", referentId: undefined });
etablissementsQuery.refetch();
},
onError: (err) => toast.error(err.message),
});
const referents = usersQuery.data?.filter((u) => u.sonumRole === "referent" || u.sonumRole === "gestionnaire") ?? [];
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
@@ -179,11 +578,10 @@ function EtablissementsPanel() {
</button>
</div>
{/* Formulaire de création */}
{showCreate && (
<div className="px-5 py-4 border-b border-border bg-muted/20">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mb-3">
<div className="sm:col-span-2 lg:col-span-1">
<label className="block text-xs font-medium text-foreground mb-1">Nom *</label>
<input
value={form.nom}
@@ -219,6 +617,19 @@ function EtablissementsPanel() {
placeholder="MCO, EHPAD..."
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Référent numérique</label>
<select
value={form.referentId ?? ""}
onChange={(e) => setForm((f) => ({ ...f, referentId: e.target.value ? Number(e.target.value) : undefined }))}
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"
>
<option value=""> Non assigné </option>
{referents.map((r) => (
<option key={r.id} value={r.id}>{r.name ?? r.email}</option>
))}
</select>
</div>
</div>
<div className="flex gap-2">
<button
@@ -240,40 +651,65 @@ function EtablissementsPanel() {
)}
{etablissementsQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
</div>
) : (
<div className="overflow-x-auto max-h-96 overflow-y-auto">
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0">
<thead className="sticky top-0 z-10">
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-5 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">Établissement</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden md:table-cell">Région</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Type</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden lg:table-cell">Référent</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Référent</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider hidden xl:table-cell">Adhérents affectés</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{etablissementsQuery.data?.map((etab) => (
<tr key={etab.id} className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3">
<div className="font-medium text-foreground text-sm">{etab.nom}</div>
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
</td>
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground text-sm">{etab.region ?? "—"}</td>
<td className="px-4 py-3 hidden lg:table-cell">
{etab.typeActivite ? (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
{etab.typeActivite}
</span>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3 hidden lg:table-cell text-muted-foreground text-xs">
{etab.referentId ? `Réf. #${etab.referentId}` : <span className="italic">Non assigné</span>}
</td>
</tr>
))}
{etablissementsQuery.data?.map((etab) => {
const adherentsAffectes = usersQuery.data?.filter(
(u) => u.sonumRole === "adherent" && (u.etablissements ?? []).some((e: any) => e.id === etab.id)
) ?? [];
const referent = usersQuery.data?.find((u) => u.id === etab.referentId);
return (
<tr key={etab.id} className="hover:bg-muted/20 transition-colors">
<td className="px-5 py-3">
<div className="font-medium text-foreground text-sm">{etab.nom}</div>
{etab.finess && <div className="text-xs text-muted-foreground">FINESS : {etab.finess}</div>}
</td>
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground text-sm">{etab.region ?? "—"}</td>
<td className="px-4 py-3 hidden lg:table-cell">
{etab.typeActivite ? (
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded border border-border">
{etab.typeActivite}
</span>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3 hidden xl:table-cell text-muted-foreground text-xs">
{referent ? (
<span className="font-medium text-foreground">{referent.name ?? referent.email}</span>
) : (
<span className="italic">Non assigné</span>
)}
</td>
<td className="px-4 py-3 hidden xl:table-cell">
{adherentsAffectes.length > 0 ? (
<div className="flex flex-wrap gap-1">
{adherentsAffectes.map((a) => (
<span key={a.id} className="text-xs bg-emerald-50 text-emerald-700 border border-emerald-200 px-2 py-0.5 rounded-full">
{a.name ?? a.email}
</span>
))}
</div>
) : (
<span className="text-xs text-muted-foreground italic">Aucun</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,88 @@
import { getLoginUrl } from "@/const";
import { useLocation } from "wouter";
import { Building2, KeyRound, ExternalLink } from "lucide-react";
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>
<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>
</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
</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>
</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>
);
}

View File

@@ -0,0 +1,151 @@
import { trpc } from "@/lib/trpc";
import { getLoginUrl } from "@/const";
import { useState } from "react";
import { useLocation } from "wouter";
import { toast } from "sonner";
import { Eye, EyeOff, Lock, Mail, ArrowLeft, ExternalLink } from "lucide-react";
export default function LoginLocal() {
const [, navigate] = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const loginMutation = trpc.auth.loginLocal.useMutation({
onSuccess: () => {
// Forcer un rechargement complet pour réinitialiser le contexte auth
window.location.href = "/";
},
onError: (err) => {
toast.error(err.message || "Email ou mot de passe incorrect");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast.error("Veuillez renseigner votre email et votre mot de passe");
return;
}
loginMutation.mutate({ email, password });
};
return (
<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
</p>
</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>
</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>
</div>
);
}

View File

@@ -0,0 +1,20 @@
CREATE TABLE `local_credentials` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`passwordHash` varchar(255) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`),
CONSTRAINT `local_credentials_userId_unique` UNIQUE(`userId`)
);
--> statement-breakpoint
CREATE TABLE `user_etablissements` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`etablissementId` int NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `user_etablissements_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `users` MODIFY COLUMN `openId` varchar(64);--> statement-breakpoint
ALTER TABLE `users` MODIFY COLUMN `sonumRole` enum('referent','gestionnaire','adherent') NOT NULL DEFAULT 'referent';

View File

@@ -0,0 +1,785 @@
{
"version": "5",
"dialect": "mysql",
"id": "71420563-53eb-41c0-b873-b65ca13fb3fb",
"prevId": "0242a5e6-a3ef-4093-887e-4d05cea98433",
"tables": {
"blocs_fonctionnels": {
"name": "blocs_fonctionnels",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"estValide": {
"name": "estValide",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"blocs_fonctionnels_id": {
"name": "blocs_fonctionnels_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"consultations": {
"name": "consultations",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"etablissementId": {
"name": "etablissementId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consultePar": {
"name": "consultePar",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consulteParNom": {
"name": "consulteParNom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"consultations_id": {
"name": "consultations_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"demandes_contact": {
"name": "demandes_contact",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"etablissementCibleId": {
"name": "etablissementCibleId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"demandeurId": {
"name": "demandeurId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"demandeurNom": {
"name": "demandeurNom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"demandeurEmail": {
"name": "demandeurEmail",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('en_attente','repondu','ferme')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en_attente'"
},
"reponse": {
"name": "reponse",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reponsePar": {
"name": "reponsePar",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reponduAt": {
"name": "reponduAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"demandes_contact_id": {
"name": "demandes_contact_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"editeurs": {
"name": "editeurs",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"estValide": {
"name": "estValide",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"editeurs_id": {
"name": "editeurs_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"etablissements": {
"name": "etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"finess": {
"name": "finess",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"region": {
"name": "region",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"departement": {
"name": "departement",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"typeActivite": {
"name": "typeActivite",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tailleEffectifs": {
"name": "tailleEffectifs",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"referentId": {
"name": "referentId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"visibilite": {
"name": "visibilite",
"type": "enum('tous','gestionnaires')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'tous'"
},
"accepteMiseEnRelation": {
"name": "accepteMiseEnRelation",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"etablissements_id": {
"name": "etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"local_credentials": {
"name": "local_credentials",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"local_credentials_id": {
"name": "local_credentials_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"local_credentials_userId_unique": {
"name": "local_credentials_userId_unique",
"columns": [
"userId"
]
}
},
"checkConstraint": {}
},
"logiciels_etablissements": {
"name": "logiciels_etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"etablissementId": {
"name": "etablissementId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"solutionId": {
"name": "solutionId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etatDeploiement": {
"name": "etatDeploiement",
"type": "enum('demarrage','en_cours','operationnel','en_remplacement')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"modeHebergement": {
"name": "modeHebergement",
"type": "enum('hds','on_premise','hybride')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"modeFacturation": {
"name": "modeFacturation",
"type": "enum('saas','achat_maintenance','location')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"interoperabilite": {
"name": "interoperabilite",
"type": "enum('non','oui_interface','oui_eai')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"versionMajeure": {
"name": "versionMajeure",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commentaire": {
"name": "commentaire",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contactNom": {
"name": "contactNom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contactFonction": {
"name": "contactFonction",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contactEmail": {
"name": "contactEmail",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"saisiePar": {
"name": "saisiePar",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"logiciels_etablissements_id": {
"name": "logiciels_etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"solutions": {
"name": "solutions",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"editeurId": {
"name": "editeurId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"blocFonctionnelId": {
"name": "blocFonctionnelId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"estValide": {
"name": "estValide",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"solutions_id": {
"name": "solutions_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"user_etablissements": {
"name": "user_etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementId": {
"name": "etablissementId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_etablissements_id": {
"name": "user_etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"sonumRole": {
"name": "sonumRole",
"type": "enum('referent','gestionnaire','adherent')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'referent'"
},
"cguAccepted": {
"name": "cguAccepted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"cguAcceptedAt": {
"name": "cguAcceptedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1776268633192,
"tag": "0001_wide_frightful_four",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1776325170672,
"tag": "0002_fast_luckman",
"breakpoints": true
}
]
}

View File

@@ -12,13 +12,19 @@ import {
export const users = mysqlTable("users", {
id: int("id").autoincrement().primaryKey(),
openId: varchar("openId", { length: 64 }).notNull().unique(),
// openId peut être null pour les comptes créés manuellement (connexion locale uniquement)
openId: varchar("openId", { length: 64 }).unique(),
name: text("name"),
email: varchar("email", { length: 320 }),
loginMethod: varchar("loginMethod", { length: 64 }),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
// Profil SONUM : referent = référent numérique, gestionnaire = gestionnaire SONUM
sonumRole: mysqlEnum("sonumRole", ["referent", "gestionnaire"]).default("referent").notNull(),
// Profil SONUM :
// referent = référent numérique (saisit les logiciels de ses établissements)
// gestionnaire = gestionnaire SONUM (accès admin complet)
// adherent = adhérent FEHAP (consultation des fiches de ses établissements affectés)
sonumRole: mysqlEnum("sonumRole", ["referent", "gestionnaire", "adherent"])
.default("referent")
.notNull(),
// CGU acceptée
cguAccepted: boolean("cguAccepted").default(false).notNull(),
cguAcceptedAt: timestamp("cguAcceptedAt"),
@@ -30,12 +36,39 @@ export const users = mysqlTable("users", {
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
// ─── Authentification locale ──────────────────────────────────────────────────
// Stocke le hash bcrypt du mot de passe pour les comptes créés manuellement.
// Un utilisateur OAuth peut aussi avoir un mot de passe local (double connexion possible).
export const localCredentials = mysqlTable("local_credentials", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull().unique(), // FK → users.id
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type LocalCredential = typeof localCredentials.$inferSelect;
// ─── Affectation Adhérents ↔ Établissements ───────────────────────────────────
// Permet au gestionnaire SONUM d'affecter des établissements à un adhérent FEHAP.
// Un adhérent ne voit que les établissements qui lui sont affectés.
export const userEtablissements = mysqlTable("user_etablissements", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(), // FK → users.id (adhérent)
etablissementId: int("etablissementId").notNull(), // FK → etablissements.id
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type UserEtablissement = typeof userEtablissements.$inferSelect;
// ─── Référentiel : Éditeurs ───────────────────────────────────────────────────
export const editeurs = mysqlTable("editeurs", {
id: int("id").autoincrement().primaryKey(),
nom: varchar("nom", { length: 255 }).notNull(),
estValide: boolean("estValide").default(true).notNull(), // false = ajouté par un référent, en attente de validation FEHAP
estValide: boolean("estValide").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
@@ -75,11 +108,8 @@ export const etablissements = mysqlTable("etablissements", {
departement: varchar("departement", { length: 100 }),
typeActivite: varchar("typeActivite", { length: 100 }),
tailleEffectifs: varchar("tailleEffectifs", { length: 50 }),
// Référent numérique responsable
referentId: int("referentId"),
// Visibilité : "tous" = visible par tous les référents, "gestionnaires" = visible uniquement par gestionnaires SONUM
visibilite: mysqlEnum("visibilite", ["tous", "gestionnaires"]).default("tous").notNull(),
// Acceptation mise en relation
accepteMiseEnRelation: boolean("accepteMiseEnRelation").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
@@ -99,24 +129,15 @@ export const logicielsEtablissements = mysqlTable("logiciels_etablissements", {
"operationnel",
"en_remplacement",
]).notNull(),
modeHebergement: mysqlEnum("modeHebergement", [
"hds",
"on_premise",
"hybride",
]),
modeFacturation: mysqlEnum("modeFacturation", [
"saas",
"achat_maintenance",
"location",
]),
modeHebergement: mysqlEnum("modeHebergement", ["hds", "on_premise", "hybride"]),
modeFacturation: mysqlEnum("modeFacturation", ["saas", "achat_maintenance", "location"]),
interoperabilite: mysqlEnum("interoperabilite", ["non", "oui_interface", "oui_eai"]),
versionMajeure: varchar("versionMajeure", { length: 50 }),
commentaire: text("commentaire"),
// Contact référent pour ce logiciel
contactNom: varchar("contactNom", { length: 255 }),
contactFonction: varchar("contactFonction", { length: 255 }),
contactEmail: varchar("contactEmail", { length: 320 }),
saisiePar: int("saisiePar"), // userId
saisiePar: int("saisiePar"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
@@ -128,7 +149,7 @@ export type LogicielEtablissement = typeof logicielsEtablissements.$inferSelect;
export const consultations = mysqlTable("consultations", {
id: int("id").autoincrement().primaryKey(),
etablissementId: int("etablissementId").notNull(),
consultePar: int("consultePar").notNull(), // userId
consultePar: int("consultePar").notNull(),
consultéParNom: varchar("consulteParNom", { length: 255 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
@@ -139,15 +160,11 @@ export type Consultation = typeof consultations.$inferSelect;
export const demandesContact = mysqlTable("demandes_contact", {
id: int("id").autoincrement().primaryKey(),
// Établissement cible
etablissementCibleId: int("etablissementCibleId").notNull(),
// Demandeur
demandeurId: int("demandeurId").notNull(),
demandeurNom: varchar("demandeurNom", { length: 255 }),
demandeurEmail: varchar("demandeurEmail", { length: 320 }),
// Message
message: text("message").notNull(),
// Statut
statut: mysqlEnum("statut", ["en_attente", "repondu", "ferme"]).default("en_attente").notNull(),
reponse: text("reponse"),
reponsePar: int("reponsePar"),

View File

@@ -47,6 +47,7 @@
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"axios": "^1.12.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -82,6 +83,7 @@
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/bcryptjs": "^3.0.0",
"@types/express": "4.17.21",
"@types/google.maps": "^3.58.1",
"@types/node": "^24.7.0",

20
pnpm-lock.yaml generated
View File

@@ -118,6 +118,9 @@ importers:
axios:
specifier: ^1.12.0
version: 1.12.2
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -218,6 +221,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.3
version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
'@types/express':
specifier: 4.17.21
version: 4.17.21
@@ -2156,6 +2162,10 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcryptjs@3.0.0':
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -2414,6 +2424,10 @@ packages:
resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==}
hasBin: true
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
body-parser@1.20.3:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -6468,6 +6482,10 @@ snapshots:
dependencies:
'@babel/types': 7.28.4
'@types/bcryptjs@3.0.0':
dependencies:
bcryptjs: 3.0.3
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -6773,6 +6791,8 @@ snapshots:
baseline-browser-mapping@2.8.12: {}
bcryptjs@3.0.3: {}
body-parser@1.20.3:
dependencies:
bytes: 3.1.2

View File

@@ -69,7 +69,7 @@ export async function updateUserCgu(userId: number) {
await db.update(users).set({ cguAccepted: true, cguAcceptedAt: new Date() }).where(eq(users.id, userId));
}
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire") {
export async function updateUserSonumRole(userId: number, sonumRole: "referent" | "gestionnaire" | "adherent") {
const db = await getDb();
if (!db) return;
await db.update(users).set({ sonumRole }).where(eq(users.id, userId));
@@ -390,3 +390,206 @@ export async function getDemandeById(id: number) {
const result = await db.select().from(demandesContact).where(eq(demandesContact.id, id)).limit(1);
return result[0] ?? null;
}
// ─── Auth locale ──────────────────────────────────────────────────────────────
import { localCredentials, userEtablissements } from "../drizzle/schema";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
/** Crée un utilisateur local (sans openId OAuth) avec un mot de passe hashé. */
export async function createLocalUser(data: {
name: string;
email: string;
sonumRole: "referent" | "gestionnaire" | "adherent";
password: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier unicité email
const existing = await db.select().from(users).where(eq(users.email, data.email)).limit(1);
if (existing.length > 0) throw new Error("EMAIL_EXISTS");
// openId synthétique pour les comptes locaux
const syntheticOpenId = `local_${nanoid(16)}`;
const passwordHash = await bcrypt.hash(data.password, 12);
const insertResult = await db.insert(users).values({
openId: syntheticOpenId,
name: data.name,
email: data.email,
loginMethod: "local",
sonumRole: data.sonumRole,
cguAccepted: false,
lastSignedIn: new Date(),
});
const userId = Number((insertResult as any)[0]?.insertId ?? 0);
if (!userId) throw new Error("Failed to create user");
await db.insert(localCredentials).values({ userId, passwordHash });
return userId;
}
/** Authentifie un utilisateur par email + mot de passe. Retourne l'utilisateur ou null. */
export async function authenticateLocalUser(email: string, password: string) {
const db = await getDb();
if (!db) return null;
const result = await db
.select({
user: users,
passwordHash: localCredentials.passwordHash,
})
.from(users)
.innerJoin(localCredentials, eq(localCredentials.userId, users.id))
.where(eq(users.email, email))
.limit(1);
if (!result.length) return null;
const { user, passwordHash } = result[0];
const valid = await bcrypt.compare(password, passwordHash);
if (!valid) return null;
// Mettre à jour lastSignedIn
await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, user.id));
return user;
}
/** Vérifie si un utilisateur possède des credentials locaux. */
export async function hasLocalCredentials(userId: number) {
const db = await getDb();
if (!db) return false;
const result = await db.select().from(localCredentials).where(eq(localCredentials.userId, userId)).limit(1);
return result.length > 0;
}
/** Met à jour le mot de passe d'un utilisateur. */
export async function updateLocalPassword(userId: number, newPassword: string) {
const db = await getDb();
if (!db) return;
const passwordHash = await bcrypt.hash(newPassword, 12);
const existing = await db.select().from(localCredentials).where(eq(localCredentials.userId, userId)).limit(1);
if (existing.length > 0) {
await db.update(localCredentials).set({ passwordHash, updatedAt: new Date() }).where(eq(localCredentials.userId, userId));
} else {
await db.insert(localCredentials).values({ userId, passwordHash });
}
}
/** Met à jour les informations d'un utilisateur. */
export async function updateUser(userId: number, data: {
name?: string;
email?: string;
sonumRole?: "referent" | "gestionnaire" | "adherent";
}) {
const db = await getDb();
if (!db) return;
await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, userId));
}
/** Supprime un utilisateur et ses credentials locaux. */
export async function deleteUser(userId: number) {
const db = await getDb();
if (!db) return;
await db.delete(localCredentials).where(eq(localCredentials.userId, userId));
await db.delete(userEtablissements).where(eq(userEtablissements.userId, userId));
await db.delete(users).where(eq(users.id, userId));
}
// ─── Affectations Adhérents ↔ Établissements ─────────────────────────────────
/** Retourne les établissements affectés à un adhérent. */
export async function getEtablissementsByAdherent(userId: number) {
const db = await getDb();
if (!db) return [];
return db
.select({
id: etablissements.id,
finess: etablissements.finess,
nom: etablissements.nom,
region: etablissements.region,
departement: etablissements.departement,
typeActivite: etablissements.typeActivite,
tailleEffectifs: etablissements.tailleEffectifs,
referentId: etablissements.referentId,
visibilite: etablissements.visibilite,
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
})
.from(userEtablissements)
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id))
.where(eq(userEtablissements.userId, userId))
.orderBy(etablissements.nom);
}
/** Retourne les IDs des établissements affectés à un adhérent. */
export async function getAffectationsByUser(userId: number) {
const db = await getDb();
if (!db) return [];
const result = await db
.select({ etablissementId: userEtablissements.etablissementId })
.from(userEtablissements)
.where(eq(userEtablissements.userId, userId));
return result.map((r) => r.etablissementId);
}
/** Affecte un établissement à un adhérent (idempotent). */
export async function assignEtablissementToUser(userId: number, etablissementId: number) {
const db = await getDb();
if (!db) return;
const existing = await db
.select()
.from(userEtablissements)
.where(and(eq(userEtablissements.userId, userId), eq(userEtablissements.etablissementId, etablissementId)))
.limit(1);
if (existing.length === 0) {
await db.insert(userEtablissements).values({ userId, etablissementId });
}
}
/** Retire un établissement d'un adhérent. */
export async function removeEtablissementFromUser(userId: number, etablissementId: number) {
const db = await getDb();
if (!db) return;
await db
.delete(userEtablissements)
.where(and(eq(userEtablissements.userId, userId), eq(userEtablissements.etablissementId, etablissementId)));
}
/** Remplace toutes les affectations d'un adhérent par une nouvelle liste. */
export async function setAffectationsForUser(userId: number, etablissementIds: number[]) {
const db = await getDb();
if (!db) return;
await db.delete(userEtablissements).where(eq(userEtablissements.userId, userId));
if (etablissementIds.length > 0) {
await db.insert(userEtablissements).values(etablissementIds.map((eid) => ({ userId, etablissementId: eid })));
}
}
/** Retourne tous les utilisateurs avec leurs affectations. */
export async function getAllUsersWithAffectations() {
const db = await getDb();
if (!db) return [];
const allUsers = await db.select().from(users).orderBy(users.name);
const allAffectations = await db
.select({
userId: userEtablissements.userId,
etablissementId: userEtablissements.etablissementId,
etablissementNom: etablissements.nom,
})
.from(userEtablissements)
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id));
return allUsers.map((u) => ({
...u,
etablissements: allAffectations
.filter((a) => a.userId === u.id)
.map((a) => ({ id: a.etablissementId, nom: a.etablissementNom })),
hasLocalCredentials: false, // sera enrichi côté router si besoin
}));
}

View File

@@ -1,14 +1,19 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
createBlocFonctionnel,
assignEtablissementToUser,
authenticateLocalUser,
createDemandeContact,
createBlocFonctionnel,
createEditeur,
createLocalUser,
createSolution,
deleteLogicielEtablissement,
deleteUser,
getAllDemandes,
getAllEtablissements,
getAllUsers,
getAllUsersWithAffectations,
getAffectationsByUser,
getBlocsFonctionnels,
getConsultationCount,
getConsultationsList,
@@ -17,11 +22,16 @@ import {
getDemandesRecuesParEtablissement,
getEditeurs,
getEtablissementById,
getEtablissementsByAdherent,
getEtablissementsByReferent,
getLogicielsByEtablissement,
getSolutions,
recordConsultation,
removeEtablissementFromUser,
repondreDemandeContact,
setAffectationsForUser,
updateLocalPassword,
updateUser,
updateUserCgu,
updateUserSonumRole,
upsertLogicielEtablissement,
@@ -35,6 +45,7 @@ import { notifyOwner } from "./_core/notification";
import { getDb } from "./db";
import { etablissements } from "../drizzle/schema";
import { eq } from "drizzle-orm";
import { sdk } from "./_core/sdk";
// ─── Middleware gestionnaire SONUM ────────────────────────────────────────────
@@ -53,11 +64,41 @@ export const appRouter = router({
// ─── Auth ──────────────────────────────────────────────────────────────────
auth: router({
me: publicProcedure.query((opts) => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
/**
* Connexion locale par email + mot de passe.
* Crée un cookie de session identique à celui de l'OAuth.
*/
loginLocal: publicProcedure
.input(z.object({
email: z.string().email(),
password: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
const user = await authenticateLocalUser(input.email, input.password);
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Email ou mot de passe incorrect" });
}
// Créer un token de session avec l'openId de l'utilisateur local
const sessionToken = await sdk.createSessionToken(user.openId!, {
name: user.name ?? "",
});
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.cookie(COOKIE_NAME, sessionToken, {
...cookieOptions,
maxAge: 1000 * 60 * 60 * 24 * 365, // 1 an
});
return { success: true, user };
}),
}),
// ─── CGU ───────────────────────────────────────────────────────────────────
@@ -108,9 +149,21 @@ export const appRouter = router({
// ─── Établissements ────────────────────────────────────────────────────────
etablissements: router({
mesEtablissements: protectedProcedure.query(({ ctx }) =>
getEtablissementsByReferent(ctx.user.id)
),
/**
* Retourne les établissements selon le rôle :
* - référent : ses établissements
* - adhérent : ses établissements affectés
* - gestionnaire : tous
*/
mesEtablissements: protectedProcedure.query(({ ctx }) => {
if (ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin") {
return getAllEtablissements();
}
if (ctx.user.sonumRole === "adherent") {
return getEtablissementsByAdherent(ctx.user.id);
}
return getEtablissementsByReferent(ctx.user.id);
}),
all: gestionnaireProcedure.query(() => getAllEtablissements()),
@@ -119,7 +172,13 @@ export const appRouter = router({
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.id);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
// Vérifier visibilité
// Adhérent : vérifier qu'il a accès à cet établissement
if (ctx.user.sonumRole === "adherent") {
const affectations = await getAffectationsByUser(ctx.user.id);
if (!affectations.includes(input.id)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (etab.referentId !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
@@ -192,6 +251,11 @@ export const appRouter = router({
.query(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
// Adhérent : vérifier affectation
if (ctx.user.sonumRole === "adherent") {
const affectations = await getAffectationsByUser(ctx.user.id);
if (!affectations.includes(input.etablissementId)) throw new TRPCError({ code: "FORBIDDEN" });
}
if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (etab.referentId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" });
}
@@ -284,7 +348,6 @@ export const appRouter = router({
message: input.message,
});
// Notification au propriétaire (gestionnaire SONUM)
await notifyOwner({
title: `Nouvelle demande de contact — ${etab.nom}`,
content: `${ctx.user.name} souhaite contacter le référent de ${etab.nom}.\n\nMessage : ${input.message}`,
@@ -321,17 +384,110 @@ export const appRouter = router({
// ─── Administration ────────────────────────────────────────────────────────
admin: router({
users: gestionnaireProcedure.query(() => getAllUsers()),
/** Liste tous les utilisateurs avec leurs établissements affectés */
users: gestionnaireProcedure.query(() => getAllUsersWithAffectations()),
/** Crée un utilisateur manuellement avec un mot de passe local */
createUser: gestionnaireProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
}))
.mutation(async ({ input }) => {
try {
const userId = await createLocalUser(input);
return { success: true, userId };
} catch (err: any) {
if (err.message === "EMAIL_EXISTS") {
throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec cet email existe déjà" });
}
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: err.message });
}
}),
/** Met à jour les informations d'un utilisateur */
updateUser: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
name: z.string().min(1).optional(),
email: z.string().email().optional(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]).optional(),
}))
.mutation(async ({ input }) => {
const { userId, ...data } = input;
await updateUser(userId, data);
return { success: true };
}),
/** Réinitialise le mot de passe d'un utilisateur local */
resetPassword: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
newPassword: z.string().min(8),
}))
.mutation(async ({ input }) => {
await updateLocalPassword(input.userId, input.newPassword);
return { success: true };
}),
/** Supprime un utilisateur */
deleteUser: gestionnaireProcedure
.input(z.object({ userId: z.number().int() }))
.mutation(async ({ input }) => {
await deleteUser(input.userId);
return { success: true };
}),
/** Ancienne procédure de mise à jour du rôle (rétrocompatibilité) */
updateRole: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
sonumRole: z.enum(["referent", "gestionnaire"]),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
}))
.mutation(async ({ input }) => {
await updateUserSonumRole(input.userId, input.sonumRole);
return { success: true };
}),
/** Retourne les établissements affectés à un utilisateur */
affectations: gestionnaireProcedure
.input(z.object({ userId: z.number().int() }))
.query(({ input }) => getAffectationsByUser(input.userId)),
/** Remplace toutes les affectations d'un adhérent */
setAffectations: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementIds: z.array(z.number().int()),
}))
.mutation(async ({ input }) => {
await setAffectationsForUser(input.userId, input.etablissementIds);
return { success: true };
}),
/** Ajoute un établissement à un utilisateur */
assignEtablissement: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementId: z.number().int(),
}))
.mutation(async ({ input }) => {
await assignEtablissementToUser(input.userId, input.etablissementId);
return { success: true };
}),
/** Retire un établissement d'un utilisateur */
removeEtablissement: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
etablissementId: z.number().int(),
}))
.mutation(async ({ input }) => {
await removeEtablissementFromUser(input.userId, input.etablissementId);
return { success: true };
}),
}),
});

249
server/sonum-v2.test.ts Normal file
View File

@@ -0,0 +1,249 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
import type { User } from "../drizzle/schema";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
openId: "test-open-id",
name: "Test User",
email: "test@example.com",
loginMethod: "local",
role: "user",
sonumRole: "referent",
cguAccepted: true,
cguAcceptedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
...overrides,
};
}
function makeCtx(user: User | null = null): TrpcContext {
const cookies: Record<string, string> = {};
return {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
cookie: (name: string, value: string, _opts: unknown) => {
cookies[name] = value;
},
clearCookie: (_name: string, _opts: unknown) => {},
} as unknown as TrpcContext["res"],
};
}
// ─── Tests : auth.me ──────────────────────────────────────────────────────────
describe("auth.me", () => {
it("retourne null quand non authentifié", async () => {
const caller = appRouter.createCaller(makeCtx(null));
const result = await caller.auth.me();
expect(result).toBeNull();
});
it("retourne l'utilisateur quand authentifié", async () => {
const user = makeUser({ name: "Alice" });
const caller = appRouter.createCaller(makeCtx(user));
const result = await caller.auth.me();
expect(result?.name).toBe("Alice");
});
});
// ─── Tests : auth.logout ──────────────────────────────────────────────────────
describe("auth.logout", () => {
it("efface le cookie de session et retourne success", async () => {
const clearedCookies: string[] = [];
const ctx: TrpcContext = {
user: makeUser(),
req: { protocol: "https", headers: {} } as TrpcContext["req"],
res: {
clearCookie: (name: string) => clearedCookies.push(name),
} as unknown as TrpcContext["res"],
};
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result.success).toBe(true);
expect(clearedCookies).toContain(COOKIE_NAME);
});
});
// ─── Tests : auth.loginLocal ──────────────────────────────────────────────────
describe("auth.loginLocal", () => {
it("rejette un email invalide", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(
caller.auth.loginLocal({ email: "not-an-email", password: "password123" })
).rejects.toThrow();
});
it("rejette un mot de passe vide", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(
caller.auth.loginLocal({ email: "test@example.com", password: "" })
).rejects.toThrow();
});
});
// ─── Tests : cgu ─────────────────────────────────────────────────────────────
describe("cgu.status", () => {
it("retourne le statut CGU de l'utilisateur", async () => {
const user = makeUser({ cguAccepted: true });
const caller = appRouter.createCaller(makeCtx(user));
const result = await caller.cgu.status();
expect(result.accepted).toBe(true);
});
it("retourne false si CGU non acceptée", async () => {
const user = makeUser({ cguAccepted: false, cguAcceptedAt: null });
const caller = appRouter.createCaller(makeCtx(user));
const result = await caller.cgu.status();
expect(result.accepted).toBe(false);
});
it("lève UNAUTHORIZED si non authentifié", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(caller.cgu.status()).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
});
// ─── Tests : gestion des rôles ────────────────────────────────────────────────
describe("admin.updateRole", () => {
it("lève FORBIDDEN pour un référent", async () => {
const user = makeUser({ sonumRole: "referent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.updateRole({ userId: 2, sonumRole: "gestionnaire" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("lève FORBIDDEN pour un adhérent", async () => {
const user = makeUser({ sonumRole: "adherent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.updateRole({ userId: 2, sonumRole: "referent" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("admin.createUser", () => {
it("lève FORBIDDEN pour un référent", async () => {
const user = makeUser({ sonumRole: "referent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.createUser({
name: "Test",
email: "test@test.com",
sonumRole: "adherent",
password: "password123",
})
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("valide que le mot de passe fait au moins 8 caractères", async () => {
const user = makeUser({ sonumRole: "gestionnaire" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.createUser({
name: "Test",
email: "test@test.com",
sonumRole: "adherent",
password: "short",
})
).rejects.toThrow();
});
});
// ─── Tests : affectations ─────────────────────────────────────────────────────
describe("admin.setAffectations", () => {
it("lève FORBIDDEN pour un non-gestionnaire", async () => {
const user = makeUser({ sonumRole: "referent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.setAffectations({ userId: 2, etablissementIds: [1, 2] })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
// ─── Tests : rôles des procédures admin ──────────────────────────────────────
describe("admin.deleteUser", () => {
it("lève FORBIDDEN pour un adhérent", async () => {
const user = makeUser({ sonumRole: "adherent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.deleteUser({ userId: 2 })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("admin.resetPassword", () => {
it("lève FORBIDDEN pour un référent", async () => {
const user = makeUser({ sonumRole: "referent" });
const caller = appRouter.createCaller(makeCtx(user));
await expect(
caller.admin.resetPassword({ userId: 2, newPassword: "newpassword123" })
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
// ─── Tests : filtrage adhérent ────────────────────────────────────────────────────────────────────────────────
describe("etablissements.byId - contrôle accès adhérent", () => {
it("lève FORBIDDEN si l'adhérent tente d'accéder à un établissement non affecté", async () => {
// L'adhérent n'a aucun établissement affecté (DB vide en test)
const user = makeUser({ sonumRole: "adherent", id: 999 });
const caller = appRouter.createCaller(makeCtx(user));
// L'établissement id=1 n'est pas affecté à l'utilisateur id=999
// La procédure doit lever FORBIDDEN ou NOT_FOUND
await expect(
caller.etablissements.byId({ id: 1 })
).rejects.toThrow();
});
});
describe("admin.setAffectations - accès gestionnaire", () => {
it("accepte la requête d'un gestionnaire (ne lève pas FORBIDDEN)", async () => {
const user = makeUser({ sonumRole: "gestionnaire" });
const caller = appRouter.createCaller(makeCtx(user));
// setAffectations avec une liste vide est idempotent et ne doit pas lever FORBIDDEN
// (peut échouer sur DB indisponible mais pas sur les permissions)
try {
await caller.admin.setAffectations({ userId: 999, etablissementIds: [] });
} catch (err: any) {
// Seule une erreur de permission est inacceptable
expect(err?.code).not.toBe("FORBIDDEN");
}
});
});
describe("auth.loginLocal - validation", () => {
it("rejette un mot de passe trop court", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(
caller.auth.loginLocal({ email: "user@test.com", password: "" })
).rejects.toThrow();
});
it("retourne UNAUTHORIZED pour des credentials inexistants", async () => {
const caller = appRouter.createCaller(makeCtx(null));
await expect(
caller.auth.loginLocal({ email: "nonexistent@test.com", password: "password123" })
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});

25
todo.md
View File

@@ -31,3 +31,28 @@
## Tests
- [x] Tests unitaires procédures tRPC (14 tests passés)
## Évolution v2 — Profil Adhérent FEHAP & Auth locale
### Base de données
- [x] Étendre enum sonumRole : ajouter "adherent"
- [x] Table local_users : id, userId (FK), passwordHash, createdAt
- [x] Vérifier que la table users supporte les comptes créés manuellement (sans openId OAuth)
### Backend tRPC
- [x] Procédure admin.createUser : créer un utilisateur manuellement (nom, email, rôle, mot de passe)
- [x] Procédure admin.listUsers : liste complète des utilisateurs avec rôle et établissements
- [x] Procédure admin.updateUser : modifier nom, email, rôle d'un utilisateur
- [x] Procédure admin.deleteUser : supprimer un utilisateur
- [x] Procédure admin.setAffectations : affecter un ou plusieurs établissements à un adhérent
- [x] Procédure admin.removeEtablissement : retirer un établissement d'un adhérent (inclus dans setAffectations)
- [x] Procédure auth.loginLocal : authentification par email + mot de passe (JWT session)
- [x] Middleware : les adhérents FEHAP voient uniquement leurs établissements affectés
### Interface
- [x] Page de choix de connexion : OAuth FEHAP vs Connexion locale
- [x] Formulaire de connexion locale (email + mot de passe)
- [x] Page admin : onglet "Gestion des utilisateurs" avec tableau et actions CRUD
- [x] Page admin : affectations établissements inline dans le tableau utilisateurs
- [x] Badge "Adhérent FEHAP" dans le header et la sidebar
- [x] Vue "Mes Établissements" filtrée pour les adhérents (uniquement les établissements affectés)