111 lines
7.3 KiB
JavaScript
111 lines
7.3 KiB
JavaScript
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>
|
||
}
|