334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { RefreshCw, Plus, Trash2, KeyRound } from 'lucide-react'
|
|
|
|
import { api } from '@/config/api'
|
|
import { useAuth } from '@/hooks/useAuth'
|
|
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog'
|
|
|
|
type UserRecord = {
|
|
id: string
|
|
username: string
|
|
displayName: string
|
|
createdAt: string
|
|
}
|
|
|
|
export const UsersPage = () => {
|
|
const { user: currentUser } = useAuth()
|
|
const [users, setUsers] = useState<UserRecord[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [success, setSuccess] = useState<string | null>(null)
|
|
|
|
// Form state
|
|
const [formUsername, setFormUsername] = useState('')
|
|
const [formPassword, setFormPassword] = useState('')
|
|
const [formDisplayName, setFormDisplayName] = useState('')
|
|
|
|
// Change password state
|
|
const [changingPasswordId, setChangingPasswordId] = useState<string | null>(null)
|
|
const [newPassword, setNewPassword] = useState('')
|
|
|
|
const canSubmit = formUsername.trim().length >= 3 && formPassword.length >= 8 && formDisplayName.trim().length > 0
|
|
|
|
const load = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await api.get<UserRecord[]>('/admin/users')
|
|
setUsers(res.data)
|
|
} catch (err) {
|
|
setError(ensureErrorMessage(err))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
}, [])
|
|
|
|
const onCreate = async () => {
|
|
if (!canSubmit) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
setSuccess(null)
|
|
|
|
try {
|
|
await api.post('/admin/users', {
|
|
username: formUsername.trim(),
|
|
password: formPassword,
|
|
displayName: formDisplayName.trim()
|
|
})
|
|
setFormUsername('')
|
|
setFormPassword('')
|
|
setFormDisplayName('')
|
|
setSuccess('Utilisateur créé')
|
|
await load()
|
|
} catch (err) {
|
|
const msg = ensureErrorMessage(err)
|
|
if (msg.includes('username_taken')) {
|
|
setError('Ce nom d\'utilisateur est déjà pris.')
|
|
} else {
|
|
setError(msg)
|
|
}
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const onChangePassword = async (id: string) => {
|
|
if (newPassword.length < 8) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
setSuccess(null)
|
|
|
|
try {
|
|
await api.patch(`/admin/users/${id}/password`, { password: newPassword })
|
|
setChangingPasswordId(null)
|
|
setNewPassword('')
|
|
setSuccess('Mot de passe modifié')
|
|
} catch (err) {
|
|
setError(ensureErrorMessage(err))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const onDelete = async (id: string) => {
|
|
setLoading(true)
|
|
setError(null)
|
|
setSuccess(null)
|
|
|
|
try {
|
|
await api.delete(`/admin/users/${id}`)
|
|
setSuccess('Utilisateur supprimé')
|
|
await load()
|
|
} catch (err) {
|
|
const msg = ensureErrorMessage(err)
|
|
if (msg.includes('cannot_delete_self')) {
|
|
setError('Vous ne pouvez pas supprimer votre propre compte.')
|
|
} else {
|
|
setError(msg)
|
|
}
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-4 max-w-[900px]">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Utilisateurs</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Gérer les comptes utilisateurs
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
|
|
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
|
Rafraîchir
|
|
</Button>
|
|
</div>
|
|
|
|
{error ? (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{success ? (
|
|
<Alert>
|
|
<AlertDescription>{success}</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{/* Formulaire d'ajout */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Créer un utilisateur</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="username">Nom d'utilisateur</Label>
|
|
<Input
|
|
id="username"
|
|
value={formUsername}
|
|
onChange={(e) => setFormUsername(e.target.value)}
|
|
placeholder="min. 3 caractères"
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
className="h-12"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="displayName">Nom d'affichage</Label>
|
|
<Input
|
|
id="displayName"
|
|
value={formDisplayName}
|
|
onChange={(e) => setFormDisplayName(e.target.value)}
|
|
placeholder="Prénom ou surnom"
|
|
className="h-12"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="password">Mot de passe</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={formPassword}
|
|
onChange={(e) => setFormPassword(e.target.value)}
|
|
placeholder="min. 8 caractères"
|
|
className="h-12"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={() => void onCreate()}
|
|
disabled={!canSubmit || loading}
|
|
className="h-12 w-full sm:w-auto"
|
|
>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
Créer
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Liste des utilisateurs */}
|
|
{loading && users.length === 0 ? (
|
|
<div className="grid gap-3">
|
|
<Skeleton className="h-20 rounded-lg" />
|
|
<Skeleton className="h-20 rounded-lg" />
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">
|
|
Comptes ({users.length})
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{users.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Aucun utilisateur</p>
|
|
) : (
|
|
<div className="grid gap-2">
|
|
{users.map((u) => {
|
|
const isSelf = currentUser?.id === u.id
|
|
|
|
return (
|
|
<div key={u.id} className="flex items-center gap-3 p-3 border rounded-lg">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium">
|
|
{u.displayName}
|
|
{isSelf ? (
|
|
<span className="text-xs text-muted-foreground ml-2">(vous)</span>
|
|
) : null}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">{u.username}</p>
|
|
|
|
{changingPasswordId === u.id ? (
|
|
<div className="flex gap-2 mt-2">
|
|
<Input
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
placeholder="Nouveau mot de passe (min. 8)"
|
|
className="h-9 text-sm"
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void onChangePassword(u.id)}
|
|
disabled={loading || newPassword.length < 8}
|
|
>
|
|
OK
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setChangingPasswordId(null)
|
|
setNewPassword('')
|
|
}}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{changingPasswordId !== u.id ? (
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
setChangingPasswordId(u.id)
|
|
setNewPassword('')
|
|
}}
|
|
disabled={loading}
|
|
title="Changer le mot de passe"
|
|
>
|
|
<KeyRound className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="ghost" size="icon" disabled={loading || isSelf} title={isSelf ? 'Vous ne pouvez pas supprimer votre propre compte' : 'Supprimer'}>
|
|
<Trash2 className={`h-4 w-4 ${isSelf ? 'text-muted-foreground' : 'text-destructive'}`} />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Supprimer cet utilisateur ?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Le compte <strong>{u.displayName}</strong> ({u.username}) sera définitivement supprimé.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => void onDelete(u.id)}>
|
|
Supprimer
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|