This commit is contained in:
Louis Mazin 2026-10-14 17:41:19 +02:00
parent 90004d4540
commit e1473076e2
7 changed files with 377 additions and 24 deletions

View File

@ -37,13 +37,15 @@ def space_rights(user, space):
if space.private_owner_id:
# Never expose someone else's private contents, including to admins.
return set(RIGHTS) if space.private_owner_id == user.pk else set()
if _is_admin(user) or space.managed_by_id == user.pk:
return set(RIGHTS)
if space.user_id == user.pk:
if _is_admin(user):
return set(RIGHTS)
# A grant can also explicitly limit an assigned owner or manager, whose
# historical roles otherwise implied full access.
grant = SpaceGrant.objects.filter(space=space, user=user).first()
if grant:
return set(grant.permissions) if grant.permissions is not None else set(legacy_rights(write=grant.can_write, invite=grant.can_invite))
if space.managed_by_id == user.pk or space.user_id == user.pk:
return set(RIGHTS)
if space.parent_id and space.can_view_parent and space.user_id == user.pk:
return set(legacy_rights(write=space.can_write_parent, invite=space.can_invite))
# Historical assigned-space access (parent) for legacy sub-users.

View File

@ -0,0 +1,154 @@
"""Run: USE_SQLITE=true python manage.py test drive.test_space_reparent_and_access"""
import tempfile
import uuid
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from .models import DriveItem, DriveSpace, SpaceGrant
from .permissions import space_rights
from .spaces import _make_numbered_space, ensure_space, ensure_private_space
from .quotas import occupancy
class SpaceReparentingTests(TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.storage = Path(tmp.name)
override = override_settings(DRIVE_STORAGE_ROOT=self.storage,
FILE_UPLOAD_TEMP_DIR=str(self.storage / '.uploads'))
override.enable()
self.addCleanup(override.disable)
User = get_user_model()
self.admin = User.objects.create_superuser('space-admin', 'admin@example.com', 'password-for-tests')
self.member = User.objects.create_user('space-member', password='password-for-tests')
self.client = APIClient()
self.client.force_authenticate(self.admin)
self.root = ensure_space(self.admin)
gb = 1024 ** 3
self.root.quota_bytes = 100 * gb
self.root.save(update_fields=['quota_bytes'])
self.a = _make_numbered_space(self.root, managed_by=self.admin,
display_name='A', quota_bytes=60 * gb)
self.b = _make_numbered_space(self.root, managed_by=self.admin,
display_name='B', quota_bytes=50 * gb)
self.child = _make_numbered_space(self.a, managed_by=self.admin,
display_name='Child', quota_bytes=30 * gb)
self.nested = _make_numbered_space(self.child, managed_by=self.admin,
display_name='Nested', quota_bytes=10 * gb)
self.old_path = self.child.relative_path
self.file = DriveItem.objects.create(owner=self.admin, space=self.nested,
name='example.pdf', size=15,
storage_key=str(uuid.uuid4()),
storage_location=f'{self.nested.relative_path}/example.pdf')
filepath = self.storage / self.file.storage_location
filepath.parent.mkdir(parents=True, exist_ok=True)
filepath.write_bytes(b'important bytes')
def test_move_subtree_preserves_files_and_nested_spaces(self):
res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/',
{'parent_space': self.b.pk}, format='json')
self.assertEqual(res.status_code, 200, res.data)
self.child.refresh_from_db()
self.nested.refresh_from_db()
self.file.refresh_from_db()
expected = f'{self.b.relative_path}/{self.child.directory_name}'
self.assertEqual(self.child.parent_id, self.b.pk)
self.assertEqual(self.child.relative_path, expected)
self.assertEqual(self.nested.relative_path, f'{expected}/{self.nested.directory_name}')
self.assertEqual(self.file.storage_location,
f'{self.nested.relative_path}/example.pdf')
self.assertEqual((self.storage / self.file.storage_location).read_bytes(), b'important bytes')
self.assertFalse((self.storage / self.old_path).exists())
self.assertEqual(occupancy(self.b)[0], 15)
self.assertEqual(occupancy(self.a)[0], 0)
def test_move_refused_if_it_would_create_cycle(self):
res = self.client.patch(f'/api/drive/spaces/{self.a.pk}/',
{'parent_space': self.nested.pk}, format='json')
self.assertEqual(res.status_code, 400, res.data)
self.a.refresh_from_db()
self.assertEqual(self.a.parent_id, self.root.pk)
self.assertTrue((self.storage / self.old_path).exists())
def test_move_rolls_back_when_new_quota_is_insufficient(self):
res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/',
{'parent_space': self.b.pk, 'quota_bytes': 0}, format='json')
self.assertEqual(res.status_code, 400, res.data)
self.child.refresh_from_db()
self.file.refresh_from_db()
self.assertEqual(self.child.parent_id, self.a.pk)
self.assertEqual(self.child.relative_path, self.old_path)
self.assertTrue((self.storage / self.file.storage_location).is_file())
def test_manage_space_without_create_right_on_target_cannot_move(self):
SpaceGrant.objects.create(user=self.member, space=self.child,
permissions=['view','manage_space'])
SpaceGrant.objects.create(user=self.member, space=self.b,
permissions=['view'])
self.client.force_authenticate(self.member)
res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/',
{'parent_space': self.b.pk}, format='json')
self.assertEqual(res.status_code, 403, res.data)
self.child.refresh_from_db()
self.assertEqual(self.child.parent_id, self.a.pk)
def test_private_space_keeps_its_separate_physical_storage(self):
SpaceGrant.objects.create(user=self.member, space=self.child,
permissions=['view','download'])
private = ensure_private_space(self.member)
self.assertTrue(private.relative_path.startswith('privates/'))
res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/',
{'parent_space': self.b.pk}, format='json')
self.assertEqual(res.status_code, 200, res.data)
private.refresh_from_db()
self.assertEqual(private.parent_id, self.child.pk)
self.assertTrue((self.storage/private.relative_path).is_dir())
def test_assigned_space_move_does_not_grant_parent_permissions(self):
assigned = _make_numbered_space(self.a, user=self.member, display_name='Assigned',
quota_bytes=10 * 1024**3, can_view_parent=True,
can_write_parent=True, can_invite=True,
parent_permissions=['view','download','add','manage_access'])
self.assertIn('manage_access', space_rights(self.member,self.a))
res = self.client.patch(f'/api/drive/spaces/{assigned.pk}/',
{'parent_space':self.b.pk}, format='json')
self.assertEqual(res.status_code,200,res.data)
assigned.refresh_from_db()
self.assertFalse(assigned.can_view_parent)
self.assertFalse(assigned.can_invite)
self.assertFalse(space_rights(self.member,self.b))
def test_move_is_blocked_by_occupied_destination_quota(self):
huge = 50 * 1024**3
DriveItem.objects.create(owner=self.admin,space=self.b,name='full.bin',size=huge,
storage_key=str(uuid.uuid4()),storage_location='legacy-file')
res = self.client.patch(f'/api/drive/spaces/{self.child.pk}/',
{'parent_space':self.b.pk},format='json')
self.assertEqual(res.status_code,400,res.data)
self.child.refresh_from_db()
self.assertEqual(self.child.parent_id,self.a.pk)
self.assertTrue((self.storage/self.old_path).is_dir())
def test_admin_edits_direct_and_assigned_access_without_creating_duplicates(self):
assigned = _make_numbered_space(self.root, user=self.member,
display_name='Assigned', quota_bytes=10 * 1024**3)
listing = self.client.get(f'/api/drive/admin/users/{self.member.pk}/access/')
self.assertEqual(listing.status_code, 200, listing.data)
self.assertEqual(sum(a['space_id'] == assigned.pk for a in listing.data['accesses']), 1)
url = f'/api/drive/admin/users/{self.member.pk}/access/owner/{assigned.pk}/'
saved = self.client.patch(url, {'permissions':['view','download']}, format='json')
self.assertEqual(saved.status_code, 200, saved.data)
self.assertEqual(space_rights(self.member, assigned), {'view','download'})
denied = self.client.patch(url, {'permissions':['modify']}, format='json')
self.assertEqual(denied.status_code, 400, denied.data)
listing = self.client.get(f'/api/drive/admin/users/{self.member.pk}/access/')
self.assertEqual(sum(a['space_id'] == assigned.pk for a in listing.data['accesses']), 1)
self.assertEqual(next(access['permissions'] for access in listing.data['accesses']
if access['space_id'] == assigned.pk), ['view','download'])
revoked = self.client.delete(url)
self.assertEqual(revoked.status_code, 204)
self.assertFalse(SpaceGrant.objects.filter(user=self.member,space=assigned).exists())

View File

@ -10,6 +10,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
from django.db import models
from django.utils import timezone
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
@ -396,6 +397,108 @@ def create_shared_space(request):
return Response(space_info(space,request.user),status=201)
def _relocate_shared_space(user, space, target_parent_id):
"""Change an entire shared-space subtree's physical and logical parent.
Called with a DB transaction and all DriveSpace rows locked. Database
changes happen before the single same-filesystem directory rename; caller
reverses the rename if the transaction fails.
"""
from .quotas import ancestry, occupancy, subtree_space_ids
from .storage_layout import disk_path
if space.private_owner_id or space.parent_id is None:
raise ValueError('L’espace racine et les espaces privés ne peuvent pas changer de parent.')
try:
target = DriveSpace.objects.get(pk=int(target_parent_id), private_owner__isnull=True)
except (TypeError, ValueError, DriveSpace.DoesNotExist):
raise ValueError('Espace parent introuvable.') from None
if target.pk == space.parent_id:
return None # No permission required to retain the existing parent.
if not has_space_right(user, space, 'manage_space') or not has_space_right(user, target, 'create_spaces'):
raise PermissionError('Tu dois pouvoir gérer cet espace et créer des espaces dans le nouveau parent.')
descendants = subtree_space_ids(space)
if target.pk in descendants:
raise ValueError('Impossible de déplacer un espace dans lui-même ou dans un de ses sous-espaces.')
if target.private_owner_id:
raise ValueError('Un espace privé ne peut pas être parent.')
old_prefix = space.relative_path
new_prefix = '/'.join(filter(None, [target.relative_path, space.directory_name]))
if not old_prefix or not space.directory_name or len(new_prefix) > 700:
raise ValueError('Déplacement impossible pour cet espace.')
# All the actual files and pending uploads of this subtree must fit in
# each NEW ancestor. Common ancestors already include that usage.
from .quotas import ancestry, occupancy
subtree_used, subtree_pending = occupancy(space)
former_ancestors = {node.pk for node in ancestry(space)}
for ancestor in ancestry(target):
if ancestor.pk in former_ancestors or ancestor.quota_bytes is None:
continue
used, pending = occupancy(ancestor)
if used + pending + subtree_used + subtree_pending > ancestor.quota_bytes:
raise ValueError('Le nouvel espace parent n’a pas assez de capacité disponible.')
caps = [node.quota_bytes for node in ancestry(target) if node.quota_bytes is not None]
max_allowed = min(caps) if caps else None
if max_allowed is not None:
if DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True).filter(
models.Q(quota_bytes__isnull=True) | models.Q(quota_bytes__gt=max_allowed)
).exists():
raise ValueError('Le quota d’un sous-espace dépasse celui du nouveau parent.')
# Refuse to move mid-upload: it can change the path during a file write.
if UploadSession.objects.filter(space_id__in=descendants).exists():
raise ValueError('Termine ou annule les transferts en cours avant de changer le parent.')
if DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=False).exclude(
relative_path__startswith='privates/').exists():
raise ValueError('Déplace d’abord les anciens espaces privés vers storage/privates/.')
source = disk_path(old_prefix)
destination = disk_path(new_prefix)
if not source.is_dir() or source.is_symlink():
raise ValueError('Le dossier de l’espace est introuvable ou invalide.')
if destination.exists() or destination.is_symlink():
raise ValueError('Un dossier existe déjà à cet emplacement.')
if not destination.parent.is_dir():
raise ValueError('Le répertoire du nouveau parent est introuvable.')
# Include all nested shared spaces, but NEVER touch storage/privates/.
affected = list(DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True))
prefix = old_prefix + '/'
for node in affected:
if node.pk != space.pk and not node.relative_path.startswith(prefix):
raise ValueError('Incohérence entre l’arborescence et le stockage physique.')
updated = new_prefix + node.relative_path[len(old_prefix):]
if len(updated) > 700:
raise ValueError('Hiérarchie trop profonde pour ce déplacement.')
node.relative_path = updated
# A rollback will restore all of these SQL updates, including the item paths.
for node in affected:
node.save(update_fields=['relative_path'])
space.parent = target
# Legacy assigned spaces could implicitly pass permissions to their parent.
# Moving them must not silently grant those rights on a DIFFERENT parent.
if space.user_id is not None:
space.can_view_parent = False
space.can_write_parent = False
space.can_invite = False
space.parent_permissions = None
space.save(update_fields=['parent','can_view_parent','can_write_parent','can_invite','parent_permissions'])
else:
space.save(update_fields=['parent'])
# Modern readable-storage keys follow the physical directory; legacy
# UUID locations outside this subtree remain untouched.
items = DriveItem.objects.filter(space_id__in=[node.pk for node in affected],
storage_location__startswith=prefix)
# Include files stored directly under old_prefix as well.
for item in items.iterator(chunk_size=500):
item.storage_location = new_prefix + item.storage_location[len(old_prefix):]
item.save(update_fields=['storage_location'])
source.rename(destination) # Atomic on this filesystem.
return source, destination
@api_view(['PATCH','DELETE'])
def edit_shared_space(request,space_id):
try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
@ -404,16 +507,33 @@ def edit_shared_space(request,space_id):
return error('Modification interdite.',403)
if request.method == 'DELETE':
return delete_shared_space(request, space_id)
moved = None
try:
with transaction.atomic():
if 'display_name' in request.data:
title=valid_name(request.data['display_name'])
if len(title)>120:return error('Nom limité à 120 caractères.')
space.display_name=title
space.save(update_fields=['display_name'])
# Prevent concurrent moves/creation or quota alterations from
# observing an incomplete hierarchy.
list(DriveSpace.objects.select_for_update().order_by('pk'))
space = DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
if 'parent_space' in request.data:
moved = _relocate_shared_space(request.user, space, request.data['parent_space'])
if 'quota_bytes' in request.data:
_update_quota(space,request.data['quota_bytes'])
except ValueError as exc:return error(str(exc))
if 'display_name' in request.data:
title=valid_name(request.data['display_name'])
if len(title)>120:raise ValueError('Nom limité à 120 caractères.')
space.display_name=title
space.save(update_fields=['display_name'])
except Exception as exc:
if moved is not None:
source, destination = moved
try:
destination.rename(source)
except OSError:
_logger.exception('ALERTE: impossible de restaurer un espace après un échec SQL: %s', source)
return error('Déplacement interrompu : intervention manuelle nécessaire.',500)
if isinstance(exc,PermissionError):return error(str(exc),403)
if isinstance(exc,(ValueError,OSError)):return error(str(exc),409 if isinstance(exc,OSError) else 400)
raise
return Response(space_info(space,request.user))
@ -589,16 +709,22 @@ def admin_user_access(request, user_id):
if is_admin(target):
return error('Impossible de modifier les accès d’un administrateur.', 403)
accesses = []
role_spaces = set()
for kind, role_filter in [('owner', {'user':target}), ('manager', {'managed_by':target})]:
for space in DriveSpace.objects.filter(private_owner__isnull=True, **role_filter):
if space.pk in role_spaces:
continue
role_spaces.add(space.pk)
accesses.append({'kind':kind, 'space_id':space.pk,
'space_name':space.display_name, 'permissions':[right for right in RIGHTS if right in space_rights(target, space)]})
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})
if grant.space_id not in role_spaces:
accesses.append({'kind':'grant', 'space_id':grant.space_id,
'space_name':grant.space.display_name, 'permissions':[right for right in RIGHTS if right in space_rights(target, grant.space)]})
return Response({'accesses': accesses})
@api_view(['DELETE'])
@api_view(['DELETE','PATCH'])
def admin_revoke_user_access(request, user_id, kind, space_id):
"""Revoke a space membership, legacy assignment, or managed-space grant.
@ -618,15 +744,35 @@ def admin_revoke_user_access(request, user_id, kind, space_id):
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 request.method == 'PATCH':
space = qs.first()
if space is None:
return error('Espace introuvable.',404)
if kind == 'owner' and space.user_id != target.pk or kind == 'manager' and space.managed_by_id != target.pk:
return error('Accès introuvable.',404)
if kind == 'grant' and not SpaceGrant.objects.filter(space=space,user=target).exists():
return error('Accès introuvable.',404)
try:
rights = normalized(request.data.get('permissions'))
except ValueError as exc:
return error(str(exc))
grant, _ = SpaceGrant.objects.select_for_update().get_or_create(user=target,space=space)
grant.permissions=rights
grant.can_write='add' in rights and 'modify' in rights
grant.can_invite='manage_access' in rights
grant.save(update_fields=['permissions','can_write','can_invite'])
return Response({'space_id':space.pk,'permissions':rights})
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)
SpaceGrant.objects.filter(space_id=space_id,user=target).delete()
else:
if not qs.filter(managed_by=target).update(managed_by=None):
return error('Accès introuvable.', 404)
SpaceGrant.objects.filter(space_id=space_id,user=target).delete()
return Response(status=204)

View File

@ -76,6 +76,7 @@ export const drive={
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}/`),
updateUserAccess:(id,kind,spaceId,permissions)=>api.patch(`/drive/admin/users/${id}/access/${kind}/${spaceId}/`,{permissions}).then(r=>r.data),
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),

View File

@ -16,6 +16,8 @@ export default function SpaceSettingsDialog({controller: c}) {
const [quota,setQuota] = useState('')
const [parent,setParent] = useState('')
const [busy,setBusy] = useState(false)
const eligibleParents = spaces.filter(space => !space.is_private && space.id !== current?.id && space.permissions?.includes('create_spaces') && !isDescendant(space, current, spaces))
const canMove = manage && !!current?.parent_id && eligibleParents.some(space => space.id !== current.parent_id)
const [localError,setLocalError] = useState('')
useEffect(() => {
setName(manage ? current?.display_name || '' : '')
@ -30,9 +32,11 @@ export default function SpaceSettingsDialog({controller: c}) {
try {
if (manage && !current) throw new Error('Sous-espace introuvable. Actualise la page.')
const data={display_name:name.trim(),quota_bytes:toQuota(quota)}
if (manage && canMove && parent && Number(parent) !== current.parent_id) data.parent_space = Number(parent)
const result=manage ? await drive.editSpace(current.id,data) : await drive.createSpace({...data,parent_space:Number(parent)})
await loadSpaces()
if (!manage) switchSpace(result.id)
if (manage && data.parent_space) await c.reloadStats()
setNote(manage ? 'Espace modifié.' : 'Espace créé.')
setSpaceDialog(null)
} catch(exception) {setLocalError(exception?.response ? message(exception) : exception.message || message(exception))}
@ -56,6 +60,14 @@ export default function SpaceSettingsDialog({controller: c}) {
className="h-10 w-full rounded-md border bg-background px-3 text-sm">
{managed.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)}
</select></div>}
{canMove && <div className="space-y-2"><Label htmlFor="space-new-parent">Espace parent</Label>
<select id="space-new-parent" value={parent} disabled={busy} onChange={e=>setParent(e.target.value)}
className="h-10 w-full rounded-md border bg-background px-3 text-sm">
{current?.parent_id && !eligibleParents.some(space => space.id === current.parent_id) &&
<option value={current.parent_id}>{spaces.find(space => space.id === current.parent_id)?.display_name || 'Parent actuel'}</option>}
{eligibleParents.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)}
</select>
</div>}
<div className="space-y-2"><Label htmlFor="space-name">Nom affiché</Label>
<Input id="space-name" value={name} onChange={e=>setName(e.target.value)} maxLength={120} required placeholder="Ex. Équipe projet" /></div>
<QuotaField id="space-quota" value={quota} onChange={setQuota} disabled={busy} />
@ -68,3 +80,15 @@ export default function SpaceSettingsDialog({controller: c}) {
</DialogContent>
</Dialog>
}
function isDescendant(candidate, ancestor, spaces) {
if (!ancestor) return false
let current = candidate
const visited = new Set()
while (current && !visited.has(current.id)) {
if (current.id === ancestor.id) return true
visited.add(current.id)
current = spaces.find(space => space.id === current.parent_id)
}
return false
}

View File

@ -65,9 +65,13 @@ export function useDriveController(user) {
useEffect(() => { loadSpaces() }, [loadSpaces])
const switchSpace = id => {
++statsSequence.current
setStats(null)
setActiveSpaceId(id)
// Clicking the current space must not erase its statistics. No dependency
// changes in that case, so the stats effect would not fetch them again.
if (id !== activeSpaceId) {
++statsSequence.current
setStats(null)
setActiveSpaceId(id)
}
setView('files')
setParent(null)
setQuery('')

View File

@ -4,6 +4,7 @@ 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'
import PermissionsEditor from '@/components/drive/PermissionsEditor'
export default function AdminUsersPage(){
const [query,setQuery] = useState('')
@ -37,6 +38,14 @@ export default function AdminUsersPage(){
catch(e){setAccessError(message(e))}
finally{setAccessBusy(false)}
}
const updateAccess=async (access,permissions)=>{
setAccessBusy(true);setAccessError('')
try {
await drive.updateUserAccess(accessUser.id,access.kind,access.space_id,permissions)
setAccesses(await drive.adminUserAccess(accessUser.id));refresh()
} 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('')
@ -75,17 +84,15 @@ export default function AdminUsersPage(){
{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">
<DialogContent className="sm:max-w-xl">
<DialogHeader><DialogTitle>Accès de @{accessUser?.username}</DialogTitle>
<DialogDescription>Retirer l’accès à un espace partagé sans supprimer cet espace.</DialogDescription>
<DialogDescription>Modifier les droits ou retirer l’accès à un 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>)}
{accesses.map(access=><AccessPermissionsRow key={`${access.kind}-${access.space_id}`}
access={access} busy={accessBusy} save={permissions=>updateAccess(access,permissions)} revoke={()=>revokeAccess(access)}/>)}
{!accessBusy && !accesses.length && <p className="text-sm text-muted-foreground">Aucun accès à un espace partagé.</p>}
</div>
</DialogContent>
@ -108,3 +115,18 @@ export default function AdminUsersPage(){
</Dialog>
</div>
}
function AccessPermissionsRow({access,busy,save,revoke}) {
const [permissions,setPermissions] = useState(access.permissions || ['view','download'])
useEffect(()=>setPermissions(access.permissions || ['view','download']),[access.permissions])
return <div className="space-y-3 rounded-xl border p-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0"><div className="truncate text-sm font-semibold">{access.space_name}</div>
<div className="text-xs text-muted-foreground">{access.kind==='grant'?'Accès direct':access.kind==='owner'?'Espace attribué':'Gestionnaire'}</div>
</div>
<Button type="button" size="icon-sm" variant="ghost" className="text-destructive" disabled={busy} title="Révoquer l’accès" onClick={revoke}><Trash2 className="size-4"/></Button>
</div>
<PermissionsEditor value={permissions} onChange={setPermissions} compact />
<Button type="button" size="sm" variant="outline" disabled={busy || permissions.join(',') === (access.permissions || []).join(',')} onClick={()=>save(permissions)}>Enregistrer les permissions</Button>
</div>
}