This commit is contained in:
Louis Mazin 2026-10-14 16:54:05 +02:00
parent 7da4c7cd67
commit 8ec0397288
8 changed files with 281 additions and 40 deletions

View File

@ -125,6 +125,6 @@ class PublicItemLink(models.Model):
class ArchivedDriveAccount(models.Model):
"""Account removed from the app while its immutable private space survives."""
"""Inactive, anonymized historical owner of shared files after account removal."""
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_archive')
deleted_at = models.DateTimeField(auto_now_add=True)

View File

@ -109,6 +109,17 @@ class SharesTests(TestCase):
self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/public-links/{links[0]["id"]}/').status_code,204)
self.assertEqual(anonymous.get(f'/api/public/{token}/').status_code,404)
# A directly shared file must download without either a login or JWT.
direct=self.client.post(f'/api/drive/items/{image.pk}/public-links/',{},format='json')
self.assertEqual(direct.status_code,201,direct.data)
direct_token=direct.data['token']
direct_preview=anonymous.get(f'/api/public/{direct_token}/')
self.assertEqual(direct_preview.status_code,200,direct_preview.data)
self.assertFalse(direct_preview.data['root']['is_folder'])
direct_file=anonymous.get(f'/api/public/{direct_token}/files/{image.pk}/')
self.assertEqual(direct_file.status_code,200)
self.assertEqual(b''.join(direct_file.streaming_content),b'PNG')
def test_admin_user_list_is_private_to_admin(self):
alice=self.invite_user('alice-admin-list')
self.client.force_authenticate(user=alice)

View File

@ -96,12 +96,18 @@ class ProtectedDeletionTests(TestCase):
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists())
self.assertTrue((self.storage/'privates').is_dir())
def test_user_deletion_anonymizes_account_but_keeps_private(self):
def test_user_deletion_removes_private_directory_and_keeps_shared_files(self):
item, path = self.private_file()
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/',
{'confirm_username':self.admin.username}, format='json').status_code, 403)
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':'wrong'}, format='json').status_code, 400)
shared = DriveItem.objects.create(owner=self.alice, space=self.root,
name='communal.txt', storage_key=str(uuid.uuid4()),
storage_location='communal.txt', size=4)
shared_path = self.storage / 'communal.txt'
shared_path.write_bytes(b'team')
with self.captureOnCommitCallbacks(execute=True):
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username}, format='json').status_code, 204)
self.alice.refresh_from_db()
@ -109,9 +115,12 @@ class ProtectedDeletionTests(TestCase):
self.assertFalse(self.alice.has_usable_password())
self.assertTrue(ArchivedDriveAccount.objects.filter(user=self.alice).exists())
self.assertNotIn(self.alice.pk, [u['id'] for u in self.client.get('/api/drive/admin/users/').data['users']])
self.assertTrue(path.exists())
self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk, private_owner=self.alice).exists())
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists())
self.assertFalse(path.exists())
self.assertFalse((self.storage / self.private.relative_path).exists())
self.assertFalse(DriveSpace.objects.filter(pk=self.private.pk).exists())
self.assertFalse(DriveItem.objects.filter(pk=item.pk).exists())
self.assertTrue(shared_path.exists())
self.assertTrue(DriveItem.objects.filter(pk=shared.pk).exists())
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username}, format='json').status_code, 404)
@ -119,3 +128,41 @@ class ProtectedDeletionTests(TestCase):
self.client.force_authenticate(user=self.alice)
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/',
{'confirm_username':self.admin.username}, format='json').status_code, 403)
def test_admin_can_revoke_space_grants_without_removing_private_storage(self):
from .models import SpaceGrant
# Access granted explicitly by invitation/existing-account grant.
grant = SpaceGrant.objects.create(user=self.alice, space=self.root,
can_write=True, can_invite=True)
rsp = self.client.get(f'/api/drive/admin/users/{self.alice.pk}/access/')
self.assertEqual(rsp.status_code, 200, rsp.data)
self.assertTrue(any(a['kind'] == 'grant' and a['space_id'] == self.root.pk
for a in rsp.data['accesses']))
self.assertNotIn(self.private.pk, [a['space_id'] for a in rsp.data['accesses']])
rsp = self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/access/grant/{self.root.pk}/')
self.assertEqual(rsp.status_code, 204)
self.assertFalse(SpaceGrant.objects.filter(pk=grant.pk).exists())
self.assertTrue(DriveSpace.objects.filter(pk=self.root.pk).exists())
self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk).exists())
# Older accounts are linked directly to their assigned shared space.
rsp = self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/access/owner/{self.assigned.pk}/')
self.assertEqual(rsp.status_code, 204)
self.assigned.refresh_from_db()
self.assertIsNone(self.assigned.user_id)
self.assertIsNone(ensure_space(self.alice))
self.client.force_authenticate(user=self.alice)
self.assertEqual(self.client.get('/api/drive/items/', {'space':self.root.pk}).status_code,403)
self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code,200)
self.assertEqual(self.client.get(f'/api/drive/admin/users/{self.alice.pk}/access/').status_code,403)
def test_account_delete_rejects_private_folder_outside_privates(self):
self.private.relative_path = f'{self.assigned.relative_path}/legacy'
self.private.save(update_fields=['relative_path'])
original = self.storage / self.private.relative_path
original.mkdir(parents=True, exist_ok=True)
rsp=self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username},format='json')
self.assertEqual(rsp.status_code,409)
self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk).exists())
self.assertTrue(original.exists())

View File

@ -5,6 +5,8 @@ urlpatterns=[
path('spaces/',views_spaces.spaces),
path('admin/users/',views_spaces.admin_users),
path('admin/users/<int:user_id>/',views_spaces.admin_delete_user),
path('admin/users/<int:user_id>/access/',views_spaces.admin_user_access),
path('admin/users/<int:user_id>/access/<str:kind>/<int:space_id>/',views_spaces.admin_revoke_user_access),
path('shares/users/search/',views_shares.search_share_users),
path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares),
path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share),

View File

@ -548,16 +548,104 @@ def admin_users(request):
return Response({'users':result[:200], 'limited':len(result)>200})
@api_view(['GET'])
def admin_user_access(request, user_id):
"""Show only shared-space access, never a user's private storage."""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
User = get_user_model()
try:
target = User.objects.get(pk=user_id, drive_archive__isnull=True)
except User.DoesNotExist:
return error('Utilisateur introuvable.', 404)
if is_admin(target):
return error('Impossible de modifier les accès d’un administrateur.', 403)
accesses = []
for grant in SpaceGrant.objects.filter(user=target, space__private_owner__isnull=True).select_related('space'):
accesses.append({'kind':'grant', 'space_id':grant.space_id, 'space_name':grant.space.display_name})
for space in DriveSpace.objects.filter(user=target, private_owner__isnull=True):
accesses.append({'kind':'owner', 'space_id':space.pk, 'space_name':space.display_name})
for space in DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True):
accesses.append({'kind':'manager', 'space_id':space.pk, 'space_name':space.display_name})
return Response({'accesses': accesses})
@api_view(['DELETE'])
def admin_revoke_user_access(request, user_id, kind, space_id):
"""Revoke a space membership, legacy assignment, or managed-space grant.
Never delete the shared space itself, its contents, or the user's private
space. Other independent grants remain intact.
"""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
if kind not in ('grant', 'owner', 'manager'):
return error('Type d’accès inconnu.', 404)
User = get_user_model()
with transaction.atomic():
try:
target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True)
except User.DoesNotExist:
return error('Utilisateur introuvable.', 404)
if is_admin(target):
return error('Impossible de modifier les accès d’un administrateur.', 403)
qs = DriveSpace.objects.select_for_update().filter(pk=space_id, private_owner__isnull=True)
if kind == 'grant':
deleted, _ = SpaceGrant.objects.filter(space__in=qs, user=target).delete()
if not deleted:return error('Accès introuvable.', 404)
elif kind == 'owner':
if not qs.filter(user=target).update(user=None):
return error('Accès introuvable.', 404)
else:
if not qs.filter(managed_by=target).update(managed_by=None):
return error('Accès introuvable.', 404)
return Response(status=204)
def _delete_staged_private(directory):
"""Physically remove a private directory after the SQL transaction commits."""
try:
if directory is not None and directory.exists():
shutil.rmtree(directory)
except OSError:
_logger.exception('Échec de suppression du dossier privé %s', directory)
def _delete_legacy_private_files(locations):
"""Remove only legacy UUID-key files stored directly at storage/ab/uuid."""
import uuid
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
for location in locations:
try:
parts = Path(location).parts
if len(parts) != 2 or len(parts[0]) != 2 or not all(c in '0123456789abcdef' for c in parts[0]):
continue
if str(uuid.UUID(parts[1])) != parts[1] or parts[0] != parts[1][:2]:
continue
# Never remove a legacy file still referenced by a shared item.
if DriveItem.objects.filter(storage_key=parts[1],storage_location='').exists():
continue
path = root.joinpath(*parts)
if path.is_symlink() or not path.resolve().is_relative_to(root):
continue
path.unlink(missing_ok=True)
except (ValueError, OSError):
_logger.exception('Impossible de supprimer le fichier privé historique : %s', location)
@api_view(['DELETE'])
def admin_delete_user(request, user_id):
"""Remove account access without deleting the non-deletable private space.
"""Deactivate an account and PERMANENTLY remove its private files and space.
The user row remains as an anonymized, inactive data owner so PROTECT
relations on private files and the private space never cascade away.
Historical shared files keep the archived owner row because their owner
FK is PROTECT; they are not removed by account deletion.
"""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
User = get_user_model()
staged = None
original = None
try:
with transaction.atomic():
try:
target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True)
@ -567,16 +655,45 @@ def admin_delete_user(request, user_id):
return error('Impossible de supprimer un administrateur.', 403)
if request.data.get('confirm_username') != target.username:
return error('Saisis le nom d’utilisateur pour confirmer.')
private = DriveSpace.objects.select_for_update().filter(private_owner=target).first()
legacy = []
if private:
# Never risk deleting an old shared directory when a private
# space has not yet been migrated to storage/privates/.
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
private_root = root / 'privates'
original = root / private.relative_path
if (not private.relative_path.startswith('privates/') or
original.parent != private_root or original.is_symlink() or
private_root.is_symlink() or not original.resolve().is_relative_to(private_root.resolve())):
return error('Déplace d’abord le stockage privé vers storage/privates.', 409)
if DriveSpace.objects.filter(parent=private).exists():
return error('Cet espace privé possède encore des sous-espaces.', 409)
# Legacy /ab/UUID paths are outside the numbered private folder.
for loc, key in DriveItem.objects.filter(space=private, is_folder=False).values_list('storage_location','storage_key'):
if key and not loc:
legacy.append(f'{key[:2]}/{key}')
if original.exists():
# Atomic rename to a location that is never browsable by
# another account. Roll back the rename on DB failure.
staged = private_root / f'.deleting-{private.pk}-{secrets.token_hex(12)}'
original.rename(staged)
# Deleting DriveItems removes their grants and public links.
DriveItem.objects.filter(space=private).delete()
UploadSession.objects.filter(space=private).delete()
private.delete()
now = timezone.now()
SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now)
PublicItemLink.objects.filter(Q(created_by=target) | Q(item__space__private_owner=target),
revoked_at__isnull=True).update(revoked_at=now)
PublicItemLink.objects.filter(created_by=target, revoked_at__isnull=True).update(revoked_at=now)
ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete()
SpaceGrant.objects.filter(user=target).delete()
# Shared spaces survive, but their former owner loses access.
DriveSpace.objects.filter(user=target, private_owner__isnull=True).update(user=None)
DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True).update(managed_by=None)
unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk',flat=True))
UploadSession.objects.filter(owner=target).delete()
if unfinished:
transaction.on_commit(lambda: _remove_upload_parts(unfinished))
target.is_active = False
target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}'
target.first_name = ''
@ -585,4 +702,15 @@ def admin_delete_user(request, user_id):
target.set_unusable_password()
target.save(update_fields=['is_active','username','first_name','last_name','email','password'])
ArchivedDriveAccount.objects.create(user=target)
# Work only after a successful DB commit; TestCase can explicitly
# execute callbacks to validate the physical removal.
def complete_cleanup():
_delete_staged_private(staged)
_delete_legacy_private_files(legacy)
_remove_upload_parts(unfinished)
transaction.on_commit(complete_cleanup)
except Exception:
if staged is not None and staged.exists() and original is not None and not original.exists():
staged.rename(original)
raise
return Response(status=204)

View File

@ -4,9 +4,11 @@ import { api, logoutTokens, savedToken } from '@/api/client'
import DrivePage from '@/pages/DrivePage'
import LoginPage from '@/pages/LoginPage'
import InvitationPage from '@/pages/InvitationPage'
import PublicSharePage from '@/pages/PublicSharePage'
/** Application entry point: only restores the session and selects the screen. */
export default function App() {
const publicToken = window.location.pathname.match(/^\/s\/([A-Za-z0-9_-]+)\/?$/)?.[1]
const invitationToken = window.location.pathname.match(/^\/invite\/([A-Za-z0-9_-]+)\/?$/)?.[1]
const [user,setUser] = useState(null)
const [checking,setChecking] = useState(true)
@ -14,6 +16,10 @@ export default function App() {
const [attempt,setAttempt] = useState(0)
useEffect(() => {
if (publicToken || invitationToken) {
setChecking(false)
return
}
let active = true
const probe = async () => {
if(!savedToken()) {setChecking(false); return}
@ -34,8 +40,9 @@ export default function App() {
const expired = () => {setUser(null);setChecking(false)}
window.addEventListener('drive:logout', expired)
return () => {active = false;window.removeEventListener('drive:logout', expired)}
}, [attempt])
}, [attempt, publicToken, invitationToken])
if(publicToken) return <PublicSharePage token={publicToken} />
if(invitationToken) return <InvitationPage token={invitationToken} />
if(connectionError) return <div className="grid min-h-dvh place-items-center p-6 text-center"><div className="space-y-4"><p>GoodDrive est momentanément inaccessible. Ta session est conservée.</p><button type="button" className="rounded-lg bg-primary px-4 py-2 text-primary-foreground" onClick={() => {setConnectionError(false);setChecking(true);setAttempt(n=>n+1)}}>Réessayer</button></div></div>
if(checking) return <div className="grid h-dvh place-items-center"><LoaderCircle className="size-7 animate-spin text-primary" /></div>

View File

@ -6,6 +6,12 @@ export const api = axios.create({
headers: { Accept: 'application/json' },
});
const raw = axios.create({baseURL:'/api', headers:{Accept:'application/json'}})
// Public links must remain accessible without a session, even if a browser
// has a previously expired access token stored locally.
const publicApi = axios.create({
baseURL: api.defaults.baseURL,
headers: { Accept: 'application/json' },
})
export const savedToken = () => sessionStorage.getItem(ACCESS)
export const storeTokens = data => {
if (data.access) sessionStorage.setItem(ACCESS,data.access)
@ -64,9 +70,11 @@ export const drive={
unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`),
publicItem:id=>api.post(`/drive/items/${id}/public-links/`).then(r=>r.data),
revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`),
publicBrowse:(token,parent)=>api.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data),
publicBrowse:(token,parent)=>publicApi.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data),
adminUsers:q=>api.get('/drive/admin/users/',{params:{q}}).then(r=>r.data),
adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}),
adminUserAccess:id=>api.get(`/drive/admin/users/${id}/access/`).then(r=>r.data.accesses),
revokeUserAccess:(id,kind,spaceId)=>api.delete(`/drive/admin/users/${id}/access/${kind}/${spaceId}/`),
stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data),
folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data),
patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),
@ -117,4 +125,4 @@ export const drive={
}
export function message(error){let d=error?.response?.data;if(typeof d?.detail==='string')return d.detail;if(typeof d==='object' && d!==null)return Object.entries(d).map(([k,v])=>`${k}: ${Array.isArray(v)?v.join(', '):v}`).join(' · ');return 'Une erreur est survenue. Réessaie.'}
export const publicFileUrl = (token,id) => `${api.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/`
export const publicFileUrl = (token,id) => `${publicApi.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/`

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { Users, LoaderCircle, Trash2 } from 'lucide-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'
@ -11,6 +11,10 @@ export default function AdminUsersPage(){
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('')
@ -27,6 +31,21 @@ export default function AdminUsersPage(){
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
@ -46,16 +65,35 @@ export default function AdminUsersPage(){
<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">{!user.is_admin && <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>}</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é et retiré de la liste. Son espace privé sera conservé.</DialogDescription>
<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