Files
itinova-podcasts/client/src/pages/AdminUtilisateurs.tsx
manus-admin aab11c8308 Initial commit: itinova-podcasts v1
Stack: Node.js/Express + React/Vite + tRPC + MySQL (Drizzle ORM)
Features: Gestion de podcasts, établissements, mots-clés, upload audio S3
Migrations: 0000-0002 (users, etablissements, mots_cles, podcasts, podcast_mots_cles)
2026-04-12 18:34:56 -04:00

533 lines
20 KiB
TypeScript

import { useState } from "react";
import AdminLayout from "@/components/AdminLayout";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
UserPlus,
Pencil,
Trash2,
Loader2,
Shield,
User,
Lock,
Eye,
EyeOff,
Search,
Users,
} from "lucide-react";
import { useAuth } from "@/_core/hooks/useAuth";
// ─── Types ─────────────────────────────────────────────────────────────────────
interface UserRow {
id: number;
name: string | null;
username: string | null;
email: string | null;
role: "user" | "admin";
loginMethod: string | null;
immutable: boolean | null;
createdAt: Date;
lastSignedIn: Date;
}
interface UserFormData {
name: string;
username: string;
password: string;
role: "user" | "admin";
}
// ─── Page principale ───────────────────────────────────────────────────────────
export default function AdminUtilisateurs() {
const utils = trpc.useUtils();
const { user: currentUser } = useAuth();
const { data: users, isLoading } = trpc.users.list.useQuery();
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [editUser, setEditUser] = useState<UserRow | null>(null);
const [deleteTarget, setDeleteTarget] = useState<UserRow | null>(null);
const filtered = (users ?? []).filter((u) => {
const q = search.toLowerCase();
return (
!q ||
u.name?.toLowerCase().includes(q) ||
u.username?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q)
);
});
const createMutation = trpc.users.create.useMutation({
onSuccess: () => {
toast.success("Utilisateur créé avec succès");
utils.users.list.invalidate();
setShowCreate(false);
},
onError: (e) => toast.error(e.message),
});
const updateMutation = trpc.users.update.useMutation({
onSuccess: () => {
toast.success("Utilisateur mis à jour");
utils.users.list.invalidate();
setEditUser(null);
},
onError: (e) => toast.error(e.message),
});
const deleteMutation = trpc.users.delete.useMutation({
onSuccess: () => {
toast.success("Utilisateur supprimé");
utils.users.list.invalidate();
setDeleteTarget(null);
},
onError: (e) => toast.error(e.message),
});
return (
<AdminLayout>
<div className="p-6 space-y-6">
{/* En-tête */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold text-foreground">Utilisateurs</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{users?.length ?? 0} compte{(users?.length ?? 0) !== 1 ? "s" : ""} enregistré{(users?.length ?? 0) !== 1 ? "s" : ""}
</p>
</div>
<Button onClick={() => setShowCreate(true)} className="gap-2 shrink-0">
<UserPlus className="w-4 h-4" />
Nouvel utilisateur
</Button>
</div>
{/* Barre de recherche */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Rechercher un utilisateur..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Tableau */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Users className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun utilisateur trouvé</p>
{search && <p className="text-sm mt-1">Essayez de modifier votre recherche</p>}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Nom</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground hidden sm:table-cell">Identifiant</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Rôle</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground hidden md:table-cell">Méthode</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground hidden lg:table-cell">Créé le</th>
<th className="text-right px-4 py-3 font-semibold text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((u) => {
const isCurrentUser = u.id === currentUser?.id;
const isImmutable = !!u.immutable;
return (
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
{/* Nom */}
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-semibold text-xs shrink-0">
{(u.name ?? u.username ?? "?").charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{u.name ?? <span className="text-muted-foreground italic">Sans nom</span>}
{isCurrentUser && (
<span className="ml-1.5 text-xs text-muted-foreground font-normal">(vous)</span>
)}
</p>
{u.email && (
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
)}
</div>
{isImmutable && (
<span title="Compte immuable — ne peut pas être modifié">
<Lock className="w-3.5 h-3.5 text-amber-500 shrink-0" />
</span>
)}
</div>
</td>
{/* Identifiant */}
<td className="px-4 py-3 hidden sm:table-cell">
{u.username ? (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
{u.username}
</code>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
{/* Rôle */}
<td className="px-4 py-3">
<RoleBadge role={u.role} />
</td>
{/* Méthode */}
<td className="px-4 py-3 hidden md:table-cell">
<span className="text-xs text-muted-foreground capitalize">
{u.loginMethod ?? "—"}
</span>
</td>
{/* Date */}
<td className="px-4 py-3 hidden lg:table-cell text-xs text-muted-foreground whitespace-nowrap">
{new Date(u.createdAt).toLocaleDateString("fr-FR")}
</td>
{/* Actions */}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted"
onClick={() => setEditUser(u as UserRow)}
disabled={isImmutable}
title={isImmutable ? "Compte immuable — ne peut pas être modifié" : "Modifier"}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(u as UserRow)}
disabled={isImmutable || isCurrentUser}
title={
isImmutable
? "Compte immuable — ne peut pas être supprimé"
: isCurrentUser
? "Vous ne pouvez pas supprimer votre propre compte"
: "Supprimer"
}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Modale création */}
<UserFormDialog
open={showCreate}
onClose={() => setShowCreate(false)}
title="Nouvel utilisateur"
description="Créez un compte local pour un professionnel ou un administrateur."
mode="create"
onSubmit={(data) => createMutation.mutate(data)}
isPending={createMutation.isPending}
/>
{/* Modale modification */}
{editUser && (
<UserFormDialog
open={!!editUser}
onClose={() => setEditUser(null)}
title="Modifier l'utilisateur"
description={`Modification du compte de ${editUser.name ?? editUser.username}.`}
mode="edit"
initialData={{
name: editUser.name ?? "",
username: editUser.username ?? "",
password: "",
role: editUser.role,
}}
onSubmit={(data) =>
updateMutation.mutate({
id: editUser.id,
name: data.name || undefined,
username: data.username || undefined,
password: data.password || undefined,
role: data.role,
})
}
isPending={updateMutation.isPending}
/>
)}
{/* Dialogue confirmation suppression */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer l'utilisateur ?</AlertDialogTitle>
<AlertDialogDescription>
Vous êtes sur le point de supprimer le compte de{" "}
<strong>{deleteTarget?.name ?? deleteTarget?.username}</strong>. Cette action est
irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteTarget && deleteMutation.mutate({ id: deleteTarget.id })}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending && <Loader2 className="w-4 h-4 animate-spin mr-2" />}
Supprimer définitivement
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AdminLayout>
);
}
// ─── Badge rôle ────────────────────────────────────────────────────────────────
function RoleBadge({ role }: { role: "user" | "admin" }) {
if (role === "admin") {
return (
<Badge variant="default" className="gap-1 text-xs font-medium">
<Shield className="w-3 h-3" />
Administrateur
</Badge>
);
}
return (
<Badge variant="secondary" className="gap-1 text-xs font-medium">
<User className="w-3 h-3" />
Professionnel
</Badge>
);
}
// ─── Formulaire utilisateur (création / édition) ───────────────────────────────
interface UserFormDialogProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
mode: "create" | "edit";
initialData?: UserFormData;
onSubmit: (data: UserFormData) => void;
isPending: boolean;
}
function UserFormDialog({
open,
onClose,
title,
description,
mode,
initialData,
onSubmit,
isPending,
}: UserFormDialogProps) {
const [name, setName] = useState(initialData?.name ?? "");
const [username, setUsername] = useState(initialData?.username ?? "");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"user" | "admin">(initialData?.role ?? "user");
const [showPassword, setShowPassword] = useState(false);
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
onClose();
}
};
// Sync initialData when dialog opens for edit
const handleFocus = () => {
if (mode === "edit" && initialData) {
if (!name) setName(initialData.name);
if (!username) setUsername(initialData.username);
if (!role) setRole(initialData.role);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !username.trim()) return;
if (mode === "create" && !password) return;
onSubmit({ name: name.trim(), username: username.trim(), password, role });
};
const isValid =
name.trim().length > 0 &&
username.trim().length >= 2 &&
(mode === "edit" || password.length >= 6);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md" onFocus={handleFocus}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
{/* Nom complet */}
<div className="space-y-1.5">
<Label htmlFor="uf-name">
Nom complet <span className="text-destructive">*</span>
</Label>
<Input
id="uf-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex : Marie Dupont"
required
autoFocus
/>
</div>
{/* Identifiant */}
<div className="space-y-1.5">
<Label htmlFor="uf-username">
Identifiant de connexion <span className="text-destructive">*</span>
</Label>
<Input
id="uf-username"
value={username}
onChange={(e) => setUsername(e.target.value.replace(/\s/g, ""))}
placeholder="Ex : mdupont"
minLength={2}
required
/>
<p className="text-xs text-muted-foreground">Minimum 2 caractères, sans espaces</p>
</div>
{/* Mot de passe */}
<div className="space-y-1.5">
<Label htmlFor="uf-password">
Mot de passe
{mode === "create" && <span className="text-destructive"> *</span>}
{mode === "edit" && (
<span className="text-muted-foreground font-normal text-xs ml-1">
(laisser vide pour ne pas modifier)
</span>
)}
</Label>
<div className="relative">
<Input
id="uf-password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === "create" ? "Minimum 6 caractères" : "Nouveau mot de passe..."}
minLength={mode === "create" ? 6 : undefined}
required={mode === "create"}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Rôle */}
<div className="space-y-1.5">
<Label htmlFor="uf-role">
Rôle <span className="text-destructive">*</span>
</Label>
<Select value={role} onValueChange={(v) => setRole(v as "user" | "admin")}>
<SelectTrigger id="uf-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">
<div className="flex items-center gap-2">
<User className="w-3.5 h-3.5" />
Professionnel — peut publier des podcasts
</div>
</SelectItem>
<SelectItem value="admin">
<div className="flex items-center gap-2">
<Shield className="w-3.5 h-3.5" />
Administrateur — accès complet
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={onClose} disabled={isPending}>
Annuler
</Button>
<Button type="submit" disabled={!isValid || isPending}>
{isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Enregistrement...
</>
) : mode === "create" ? (
"Créer l'utilisateur"
) : (
"Enregistrer"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}