514 lines
22 KiB
TypeScript
514 lines
22 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import {
|
|
Users,
|
|
Shield,
|
|
Plus,
|
|
Trash2,
|
|
ChevronDown,
|
|
Check,
|
|
Loader2,
|
|
Search,
|
|
X,
|
|
UserCircle,
|
|
KeyRound,
|
|
Eye,
|
|
EyeOff,
|
|
} from 'lucide-react'
|
|
import { cn, formatDate } from '@/lib/utils'
|
|
import Link from 'next/link'
|
|
|
|
interface User {
|
|
id: string
|
|
email: string
|
|
username: string
|
|
role: 'admin' | 'formateur'
|
|
created_at: string
|
|
last_sign_in: string | null
|
|
}
|
|
|
|
interface Props {
|
|
initialUsers: User[]
|
|
currentUserId: string
|
|
}
|
|
|
|
|
|
const roleConfig = {
|
|
admin: { label: 'Admin', color: 'bg-purple-500/10 text-purple-400 border-purple-500/20' },
|
|
formateur: { label: 'Formateur', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
|
}
|
|
|
|
export default function UsersClient({ initialUsers, currentUserId }: Props) {
|
|
const router = useRouter()
|
|
const [users, setUsers] = useState<User[]>(initialUsers)
|
|
const [search, setSearch] = useState('')
|
|
const [filterRole, setFilterRole] = useState<'all' | 'admin' | 'formateur'>('all')
|
|
|
|
const [changingRoleId, setChangingRoleId] = useState<string | null>(null)
|
|
const [roleDropdownId, setRoleDropdownId] = useState<string | null>(null)
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// ── Changement de mot de passe ─────────────────────────────────────────
|
|
const [passwordModalUserId, setPasswordModalUserId] = useState<string | null>(null)
|
|
const [passwordModalUsername, setPasswordModalUsername] = useState('')
|
|
const [newPassword, setNewPassword] = useState('')
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
|
const [savingPassword, setSavingPassword] = useState(false)
|
|
const [passwordError, setPasswordError] = useState<string | null>(null)
|
|
const [passwordSuccess, setPasswordSuccess] = useState(false)
|
|
|
|
const openPasswordModal = (user: User) => {
|
|
setPasswordModalUserId(user.id)
|
|
setPasswordModalUsername(user.username)
|
|
setNewPassword('')
|
|
setConfirmPassword('')
|
|
setShowPassword(false)
|
|
setShowConfirmPassword(false)
|
|
setPasswordError(null)
|
|
setPasswordSuccess(false)
|
|
setRoleDropdownId(null)
|
|
}
|
|
|
|
const closePasswordModal = () => {
|
|
setPasswordModalUserId(null)
|
|
setNewPassword('')
|
|
setConfirmPassword('')
|
|
setPasswordError(null)
|
|
setPasswordSuccess(false)
|
|
}
|
|
|
|
const handleChangePassword = async () => {
|
|
setPasswordError(null)
|
|
if (newPassword.length < 8) {
|
|
setPasswordError('Le mot de passe doit contenir au moins 8 caractères.')
|
|
return
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
setPasswordError('Les mots de passe ne correspondent pas.')
|
|
return
|
|
}
|
|
setSavingPassword(true)
|
|
try {
|
|
const res = await fetch(`/api/admin/users/${passwordModalUserId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: newPassword }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
setPasswordError(data.error ?? 'Erreur lors du changement de mot de passe.')
|
|
} else {
|
|
setPasswordSuccess(true)
|
|
setTimeout(closePasswordModal, 1500)
|
|
}
|
|
} finally {
|
|
setSavingPassword(false)
|
|
}
|
|
}
|
|
|
|
// ── Rôle ────────────────────────────────────────────────────────────────
|
|
const handleChangeRole = async (userId: string, newRole: 'admin' | 'formateur') => {
|
|
setChangingRoleId(userId)
|
|
setRoleDropdownId(null)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/api/admin/users/${userId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ role: newRole }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
setError(data.error)
|
|
} else {
|
|
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u)))
|
|
}
|
|
} finally {
|
|
setChangingRoleId(null)
|
|
}
|
|
}
|
|
|
|
// ── Suppression ─────────────────────────────────────────────────────────
|
|
const handleDelete = async (userId: string) => {
|
|
setDeletingId(userId)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
setError(data.error)
|
|
} else {
|
|
setUsers((prev) => prev.filter((u) => u.id !== userId))
|
|
setConfirmDeleteId(null)
|
|
}
|
|
} finally {
|
|
setDeletingId(null)
|
|
}
|
|
}
|
|
|
|
const adminCount = users.filter((u) => u.role === 'admin').length
|
|
const formateurCount = users.filter((u) => u.role === 'formateur').length
|
|
|
|
const filtered = users.filter((u) => {
|
|
const q = search.toLowerCase()
|
|
const matchSearch = !q || u.email.toLowerCase().includes(q) || u.username.toLowerCase().includes(q)
|
|
const matchRole = filterRole === 'all' || u.role === filterRole
|
|
return matchSearch && matchRole
|
|
})
|
|
|
|
return (
|
|
<div className="p-4 md:p-8">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-6 md:mb-8 gap-3">
|
|
<div>
|
|
<h1 className="text-2xl md:text-3xl font-bold text-text-primary mb-1 md:mb-2">Gestion des Utilisateurs</h1>
|
|
<p className="text-text-secondary text-sm hidden sm:block">
|
|
Créez et gérez les comptes formateurs et administrateurs de la plateforme.
|
|
</p>
|
|
</div>
|
|
<Link href="/dashboard/admin/users/create" className="btn-primary flex items-center gap-2 flex-shrink-0 text-sm">
|
|
<Plus size={16} />
|
|
<span className="hidden sm:inline">Créer un compte</span>
|
|
<span className="sm:hidden">Créer</span>
|
|
</Link>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-6 bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-lg text-sm flex items-center justify-between">
|
|
{error}
|
|
<button onClick={() => setError(null)}><X size={14} /></button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-3 gap-3 md:gap-4 mb-6 md:mb-8">
|
|
{[
|
|
{ icon: Users, label: 'Total', value: users.length, color: 'text-text-secondary' },
|
|
{ icon: Shield, label: 'Admins', value: adminCount, color: 'text-purple-400' },
|
|
{ icon: UserCircle, label: 'Formateurs', value: formateurCount, color: 'text-blue-400' },
|
|
].map(({ icon: Icon, label, value, color }) => (
|
|
<div key={label} className="card p-3 md:p-5 flex items-center gap-3 md:gap-4">
|
|
<div className={cn('w-9 h-9 md:w-12 md:h-12 rounded-xl bg-background-elevated flex items-center justify-center flex-shrink-0', color)}>
|
|
<Icon size={18} />
|
|
</div>
|
|
<div>
|
|
<p className="text-text-secondary text-xs md:text-sm">{label}</p>
|
|
<p className="text-xl md:text-2xl font-bold text-text-primary">{value}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 mb-5">
|
|
<div className="relative flex-1 sm:max-w-xs">
|
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Rechercher un utilisateur..."
|
|
className="input-field pl-9 py-2 text-sm w-full"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1 bg-background-card border border-border rounded-lg p-1">
|
|
{(['all', 'admin', 'formateur'] as const).map((r) => (
|
|
<button
|
|
key={r}
|
|
onClick={() => setFilterRole(r)}
|
|
className={cn(
|
|
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
|
filterRole === r
|
|
? 'bg-primary/20 text-primary font-medium'
|
|
: 'text-text-muted hover:text-text-primary'
|
|
)}
|
|
>
|
|
{r === 'all' ? 'Tous' : r === 'admin' ? 'Admins' : 'Formateurs'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="card overflow-hidden overflow-x-auto">
|
|
<table className="w-full min-w-[600px]">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider">Utilisateur</th>
|
|
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider hidden sm:table-cell">Email</th>
|
|
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider">Rôle</th>
|
|
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider hidden md:table-cell">Créé le</th>
|
|
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider hidden lg:table-cell">Dernière connexion</th>
|
|
<th className="text-right text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-text-muted">
|
|
Aucun utilisateur trouvé
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filtered.map((user) => {
|
|
const isSelf = user.id === currentUserId
|
|
const roleCfg = roleConfig[user.role] ?? roleConfig.formateur
|
|
const isConfirmingDelete = confirmDeleteId === user.id
|
|
const isDeleting = deletingId === user.id
|
|
const isChanging = changingRoleId === user.id
|
|
|
|
return (
|
|
<tr key={user.id} className="border-b border-border/50 hover:bg-background-elevated/20 transition-colors">
|
|
{/* Avatar + Username */}
|
|
<td className="px-4 md:px-6 py-3 md:py-4">
|
|
<div className="flex items-center gap-2 md:gap-3">
|
|
<div className="w-8 h-8 md:w-9 md:h-9 bg-primary/20 rounded-full flex items-center justify-center text-primary font-semibold text-sm flex-shrink-0">
|
|
{user.username.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-text-primary">{user.username}</p>
|
|
{isSelf && <p className="text-xs text-primary/70">Vous</p>}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Email */}
|
|
<td className="px-4 md:px-6 py-3 md:py-4 text-sm text-text-secondary hidden sm:table-cell">{user.email}</td>
|
|
|
|
{/* Rôle (dropdown) */}
|
|
<td className="px-4 md:px-6 py-3 md:py-4">
|
|
<div className="relative">
|
|
<button
|
|
disabled={isSelf || isChanging}
|
|
onClick={() => setRoleDropdownId(roleDropdownId === user.id ? null : user.id)}
|
|
className={cn(
|
|
'flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border font-medium transition-colors',
|
|
roleCfg.color,
|
|
!isSelf && !isChanging && 'hover:opacity-80 cursor-pointer',
|
|
isSelf && 'cursor-default opacity-70'
|
|
)}
|
|
>
|
|
{isChanging ? (
|
|
<Loader2 size={11} className="animate-spin" />
|
|
) : (
|
|
roleCfg.label
|
|
)}
|
|
{!isSelf && !isChanging && <ChevronDown size={11} />}
|
|
</button>
|
|
|
|
{roleDropdownId === user.id && (
|
|
<div className="absolute left-0 top-8 z-20 bg-background-card border border-border rounded-lg shadow-xl overflow-hidden w-36">
|
|
{(['formateur', 'admin'] as const).map((r) => (
|
|
<button
|
|
key={r}
|
|
onClick={() => handleChangeRole(user.id, r)}
|
|
className={cn(
|
|
'w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-background-elevated transition-colors',
|
|
user.role === r ? 'text-primary font-medium' : 'text-text-primary'
|
|
)}
|
|
>
|
|
{r === 'admin' ? 'Admin' : 'Formateur'}
|
|
{user.role === r && <Check size={13} className="text-primary" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
|
|
{/* Created at */}
|
|
<td className="px-4 md:px-6 py-3 md:py-4 text-sm text-text-secondary hidden md:table-cell">{formatDate(user.created_at)}</td>
|
|
|
|
{/* Last sign in */}
|
|
<td className="px-4 md:px-6 py-3 md:py-4 text-sm text-text-secondary hidden lg:table-cell">{formatDate(user.last_sign_in)}</td>
|
|
|
|
{/* Actions */}
|
|
<td className="px-4 md:px-6 py-3 md:py-4">
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
{/* Bouton mot de passe — toujours visible, y compris pour soi-même */}
|
|
<button
|
|
onClick={() => openPasswordModal(user)}
|
|
className="p-1.5 rounded-md text-text-muted hover:text-amber-400 hover:bg-amber-500/10 transition-colors"
|
|
title="Changer le mot de passe"
|
|
>
|
|
<KeyRound size={15} />
|
|
</button>
|
|
|
|
{/* Bouton suppression */}
|
|
{isConfirmingDelete ? (
|
|
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/20 rounded-lg px-2.5 py-1">
|
|
<span className="text-xs text-red-400">Supprimer ?</span>
|
|
<button
|
|
onClick={() => handleDelete(user.id)}
|
|
disabled={isDeleting}
|
|
className="text-xs text-red-400 hover:text-red-300 font-medium"
|
|
>
|
|
{isDeleting ? <Loader2 size={11} className="animate-spin" /> : 'Oui'}
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmDeleteId(null)}
|
|
className="text-xs text-text-muted hover:text-text-primary"
|
|
>
|
|
Non
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
disabled={isSelf}
|
|
onClick={() => { setConfirmDeleteId(user.id); setRoleDropdownId(null) }}
|
|
className={cn(
|
|
'p-1.5 rounded-md transition-colors',
|
|
isSelf
|
|
? 'text-text-muted/30 cursor-not-allowed'
|
|
: 'text-text-muted hover:text-red-400 hover:bg-red-500/10'
|
|
)}
|
|
title={isSelf ? 'Vous ne pouvez pas vous supprimer' : 'Supprimer'}
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Clic en dehors ferme les dropdowns */}
|
|
{roleDropdownId && (
|
|
<div className="fixed inset-0 z-10" onClick={() => setRoleDropdownId(null)} />
|
|
)}
|
|
|
|
{/* ── Modale changement de mot de passe ─────────────────────────────── */}
|
|
{passwordModalUserId && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
<div className="bg-background-card border border-border rounded-xl p-6 w-full max-w-sm shadow-2xl">
|
|
{/* En-tête */}
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center flex-shrink-0">
|
|
<KeyRound size={18} className="text-amber-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="font-semibold text-text-primary text-sm">Changer le mot de passe</h2>
|
|
<p className="text-xs text-text-muted">{passwordModalUsername}</p>
|
|
</div>
|
|
<button
|
|
onClick={closePasswordModal}
|
|
className="ml-auto p-1 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{passwordSuccess ? (
|
|
<div className="flex items-center gap-2 bg-green-500/10 border border-green-500/20 text-green-400 px-4 py-3 rounded-lg text-sm">
|
|
<Check size={15} />
|
|
Mot de passe mis à jour avec succès.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Nouveau mot de passe */}
|
|
<div>
|
|
<label className="block text-sm text-text-secondary mb-1.5">
|
|
Nouveau mot de passe
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
placeholder="Minimum 8 caractères"
|
|
className="input-field pr-10 text-sm"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
</div>
|
|
{newPassword.length > 0 && newPassword.length < 8 && (
|
|
<p className="text-xs text-amber-400 mt-1">Encore {8 - newPassword.length} caractère(s) requis</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Confirmation */}
|
|
<div>
|
|
<label className="block text-sm text-text-secondary mb-1.5">
|
|
Confirmer le mot de passe
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showConfirmPassword ? 'text' : 'password'}
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
placeholder="Répétez le mot de passe"
|
|
className="input-field pr-10 text-sm"
|
|
onKeyDown={(e) => e.key === 'Enter' && handleChangePassword()}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowConfirmPassword((v) => !v)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
{showConfirmPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
</div>
|
|
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
|
<p className="text-xs text-red-400 mt-1">Les mots de passe ne correspondent pas</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Erreur API */}
|
|
{passwordError && (
|
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-3 py-2 rounded-lg text-sm">
|
|
{passwordError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 pt-1">
|
|
<button
|
|
onClick={closePasswordModal}
|
|
className="btn-secondary flex-1 text-sm"
|
|
disabled={savingPassword}
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={handleChangePassword}
|
|
disabled={savingPassword || newPassword.length < 8 || newPassword !== confirmPassword}
|
|
className={cn(
|
|
'btn-primary flex-1 text-sm flex items-center justify-center gap-2',
|
|
(savingPassword || newPassword.length < 8 || newPassword !== confirmPassword) && 'opacity-50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{savingPassword ? (
|
|
<>
|
|
<Loader2 size={14} className="animate-spin" />
|
|
Enregistrement…
|
|
</>
|
|
) : (
|
|
'Enregistrer'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|