stock.geolock.fr/apps/web/src/components/admin/UsersPage.tsx
2026-02-24 16:10:30 +00:00

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&apos;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&apos;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>
)
}