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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,54 +56,235 @@ 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 />
|
||||
{/* 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>
|
||||
|
||||
{/* Gestion des établissements */}
|
||||
<div className="lg:col-span-2">
|
||||
<EtablissementsPanel />
|
||||
</div>
|
||||
</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="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 justify-between px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{usersQuery.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>
|
||||
) : (
|
||||
@@ -90,58 +294,249 @@ function UsersPanel() {
|
||||
<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>
|
||||
<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) => (
|
||||
{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">
|
||||
<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 hidden md:table-cell text-muted-foreground text-xs">{u.email ?? "—"}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{isEditing ? (
|
||||
<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"
|
||||
}`}
|
||||
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">
|
||||
{u.lastSignedIn
|
||||
? new Date(u.lastSignedIn).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })
|
||||
: "—"}
|
||||
<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>
|
||||
|
||||
{/* 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,22 +651,29 @@ 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) => (
|
||||
{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>
|
||||
@@ -269,11 +687,29 @@ function EtablissementsPanel() {
|
||||
</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 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>
|
||||
|
||||
88
client/src/pages/Login.tsx
Normal file
88
client/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
client/src/pages/LoginLocal.tsx
Normal file
151
client/src/pages/LoginLocal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
drizzle/0002_fast_luckman.sql
Normal file
20
drizzle/0002_fast_luckman.sql
Normal 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';
|
||||
785
drizzle/meta/0002_snapshot.json
Normal file
785
drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
20
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
205
server/db.ts
205
server/db.ts
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
249
server/sonum-v2.test.ts
Normal 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
25
todo.md
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user