add change password feature
This commit is contained in:
parent
e90103ee2f
commit
3470c7caf0
@ -19,19 +19,44 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const { role } = await request.json()
|
||||
const body = await request.json()
|
||||
const admin = createAdminClient() as any
|
||||
|
||||
// ── Changement de mot de passe ──────────────────────────────────────
|
||||
if (body.password !== undefined) {
|
||||
const { password } = body
|
||||
if (typeof password !== 'string' || password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Le mot de passe doit contenir au moins 8 caractères.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error: pwError } = await admin.rpc('admin_update_user_password', {
|
||||
target_user_id: id,
|
||||
new_password: password,
|
||||
})
|
||||
|
||||
if (pwError) {
|
||||
console.error('[admin/users/password]', pwError)
|
||||
return NextResponse.json({ error: pwError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
// ── Changement de rôle ───────────────────────────────────────────────
|
||||
const { role } = body
|
||||
|
||||
if (!['admin', 'formateur'].includes(role)) {
|
||||
return NextResponse.json({ error: 'Rôle invalide' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Ne pas modifier son propre rôle
|
||||
if (id === caller.id) {
|
||||
return NextResponse.json({ error: 'Vous ne pouvez pas modifier votre propre rôle' }, { status: 400 })
|
||||
}
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { error: updateError } = await (admin as any)
|
||||
const { error: updateError } = await admin
|
||||
.from('profiles')
|
||||
.update({ role })
|
||||
.eq('id', id)
|
||||
|
||||
@ -13,6 +13,9 @@ import {
|
||||
Search,
|
||||
X,
|
||||
UserCircle,
|
||||
KeyRound,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
@ -57,13 +60,67 @@ export default function UsersClient({ initialUsers, currentUserId }: Props) {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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
|
||||
})
|
||||
// ── 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)
|
||||
@ -85,6 +142,7 @@ export default function UsersClient({ initialUsers, currentUserId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Suppression ─────────────────────────────────────────────────────────
|
||||
const handleDelete = async (userId: string) => {
|
||||
setDeletingId(userId)
|
||||
setError(null)
|
||||
@ -105,6 +163,13 @@ export default function UsersClient({ initialUsers, currentUserId }: Props) {
|
||||
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 */}
|
||||
@ -272,7 +337,17 @@ export default function UsersClient({ initialUsers, currentUserId }: Props) {
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 md:px-6 py-3 md:py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<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>
|
||||
@ -319,6 +394,128 @@ export default function UsersClient({ initialUsers, currentUserId }: Props) {
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user