GooDrive/frontend/src/pages/AdminUsersPage.jsx
Louis Mazin 8ec0397288 nice
2026-10-14 16:54:05 +02:00

111 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react'
import { Users, LoaderCircle, Trash2, KeyRound } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { drive, message } from '@/api/client'
export default function AdminUsersPage(){
const [query,setQuery] = useState('')
const [record,setRecord] = useState({users:[]})
const [busy,setBusy] = useState(true)
const [error,setError] = useState('')
const [selected,setSelected] = useState(null)
const [accessUser,setAccessUser] = useState(null)
const [accesses,setAccesses] = useState([])
const [accessBusy,setAccessBusy] = useState(false)
const [accessError,setAccessError] = useState('')
const [confirmation,setConfirmation] = useState('')
const [deleting,setDeleting] = useState(false)
const [deleteError,setDeleteError] = useState('')
const [revision,setRevision] = useState(0)
const refresh=useCallback(()=>setRevision(n=>n+1),[])
useEffect(()=>{
let active=true
const timer=setTimeout(async()=>{
setBusy(true)
try {const data=await drive.adminUsers(query);if(active){setRecord(data);setError('')}}
catch(e){if(active)setError(message(e))}
finally{if(active)setBusy(false)}
},query?300:0)
return ()=>{active=false;clearTimeout(timer)}
},[query,revision])
const close=()=>{if(!deleting){setSelected(null);setConfirmation('');setDeleteError('')}}
const openAccesses=async user=>{
setAccessUser(user);setAccesses([]);setAccessError('');setAccessBusy(true)
try {setAccesses(await drive.adminUserAccess(user.id))}
catch(e){setAccessError(message(e))}
finally{setAccessBusy(false)}
}
const revokeAccess=async access=>{
if(!accessUser || !window.confirm(`Retirer l’accès à « ${access.space_name} » pour @${accessUser.username} ?`))return
setAccessBusy(true);setAccessError('')
try{
await drive.revokeUserAccess(accessUser.id,access.kind,access.space_id)
setAccesses(await drive.adminUserAccess(accessUser.id));refresh()
}catch(e){setAccessError(message(e))}
finally{setAccessBusy(false)}
}
const remove=async(event)=>{
event.preventDefault()
if(!selected||deleting||confirmation!==selected.username)return
setDeleting(true);setDeleteError('')
try{await drive.adminDeleteUser(selected.id,confirmation);setSelected(null);setConfirmation('');refresh()}
catch(e){setDeleteError(message(e))}
finally{setDeleting(false)}
}
return <div className="min-h-0 flex-1 overflow-auto p-4 sm:p-7">
<div className="mx-auto max-w-5xl space-y-5">
<h1 className="flex items-center gap-3 text-2xl font-semibold"><Users/> Utilisateurs</h1>
<Input placeholder="Rechercher par nom, identifiant ou e-mail…" value={query} onChange={e=>setQuery(e.target.value)}/>
{error && <p className="text-sm text-destructive">{error}</p>}
{busy?<LoaderCircle className="size-6 animate-spin"/>:<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[650px] text-left text-sm"><thead className="bg-muted/50 text-muted-foreground"><tr><th className="p-3">Utilisateur</th><th className="p-3">Identifiant</th><th className="p-3">E-mail</th><th className="p-3">Espace attribué</th><th className="p-3">Statut</th><th className="p-3 text-right">Actions</th></tr></thead>
<tbody className="divide-y">{record.users.map(user=><tr key={user.id}>
<td className="p-3 font-medium">{[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'}</td>
<td className="p-3">@{user.username}</td><td className="p-3">{user.email||'—'}</td><td className="p-3">{user.space_name||'—'}</td>
<td className="p-3">{user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''}</td>
<td className="p-2 text-right"><div className="flex justify-end gap-1">{!user.is_admin && <>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Gérer les accès de ${user.username}`} title="Gérer les accès" onClick={()=>openAccesses(user)}><KeyRound className="size-4"/></Button>
<Button type="button" variant="ghost" size="icon-sm" className="text-destructive" aria-label={`Supprimer ${user.username}`} title="Supprimer l’utilisateur" onClick={()=>{setSelected(user);setConfirmation('');setDeleteError('')}}><Trash2 className="size-4"/></Button>
</>}</div></td>
</tr>)}</tbody></table>
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
</div>}
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
</div>
<Dialog open={!!accessUser} onOpenChange={open=>{if(!open&&!accessBusy)setAccessUser(null)}}>
<DialogContent className="sm:max-w-lg">
<DialogHeader><DialogTitle>Accès de @{accessUser?.username}</DialogTitle>
<DialogDescription>Retirer l’accès à un espace partagé sans supprimer cet espace.</DialogDescription>
</DialogHeader>
{accessError && <p role="alert" className="text-sm text-destructive">{accessError}</p>}
{accessBusy && <LoaderCircle className="size-5 animate-spin"/>}
<div className="max-h-80 space-y-2 overflow-y-auto">
{accesses.map(access=><div key={`${access.kind}-${access.space_id}`} className="flex items-center justify-between gap-2 rounded-lg border p-3">
<div className="min-w-0"><div className="truncate text-sm font-medium">{access.space_name}</div><div className="text-xs text-muted-foreground">{access.kind==='grant'?'Accès attribué':access.kind==='owner'?'Espace attribué':'Gestionnaire'}</div></div>
<Button type="button" size="sm" variant="outline" className="shrink-0 text-destructive" disabled={accessBusy} onClick={()=>revokeAccess(access)}><Trash2 className="size-4"/> Retirer</Button>
</div>)}
{!accessBusy && !accesses.length && <p className="text-sm text-muted-foreground">Aucun accès à un espace partagé.</p>}
</div>
</DialogContent>
</Dialog>
<Dialog open={!!selected} onOpenChange={open=>{if(!open)close()}}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>Supprimer l’utilisateur</DialogTitle>
<DialogDescription>Le compte sera désactivé. Son espace privé et tous ses fichiers seront définitivement supprimés.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={remove}>
<label className="block space-y-2 text-sm">Tape « {selected?.username} » pour confirmer
<Input value={confirmation} autoComplete="off" autoFocus disabled={deleting} onChange={e=>setConfirmation(e.target.value)} />
</label>
{deleteError && <p className="text-sm text-destructive" role="alert">{deleteError}</p>}
<DialogFooter><Button type="button" variant="outline" disabled={deleting} onClick={close}>Annuler</Button>
<Button type="submit" variant="destructive" disabled={deleting||confirmation!==selected?.username}>{deleting?'Suppression…':'Supprimer'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
}