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,22 +96,31 @@ 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)
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username}, format='json').status_code, 204)
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()
self.assertFalse(self.alice.is_active)
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,41 +548,169 @@ def admin_users(request):
return Response({'users':result[:200], 'limited':len(result)>200})
@api_view(['DELETE'])
def admin_delete_user(request, user_id):
"""Remove account access without deleting the non-deletable private space.
@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})
The user row remains as an anonymized, inactive data owner so PROTECT
relations on private files and the private space never cascade away.
@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 target.pk == request.user.pk or is_admin(target):
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.')
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)
ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete()
SpaceGrant.objects.filter(user=target).delete()
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 = ''
target.last_name = ''
target.email = ''
target.set_unusable_password()
target.save(update_fields=['is_active', 'username', 'first_name', 'last_name', 'email', 'password'])
ArchivedDriveAccount.objects.create(user=target)
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):
"""Deactivate an account and PERMANENTLY remove its private files and space.
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)
except User.DoesNotExist:
return error('Utilisateur introuvable.', 404)
if target.pk == request.user.pk or is_admin(target):
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(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()
target.is_active = False
target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}'
target.first_name = ''
target.last_name = ''
target.email = ''
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