nickel
This commit is contained in:
parent
90004d4540
commit
e1473076e2
@ -37,13 +37,15 @@ def space_rights(user, space):
|
|||||||
if space.private_owner_id:
|
if space.private_owner_id:
|
||||||
# Never expose someone else's private contents, including to admins.
|
# Never expose someone else's private contents, including to admins.
|
||||||
return set(RIGHTS) if space.private_owner_id == user.pk else set()
|
return set(RIGHTS) if space.private_owner_id == user.pk else set()
|
||||||
if _is_admin(user) or space.managed_by_id == user.pk:
|
if _is_admin(user):
|
||||||
return set(RIGHTS)
|
|
||||||
if space.user_id == user.pk:
|
|
||||||
return set(RIGHTS)
|
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()
|
grant = SpaceGrant.objects.filter(space=space, user=user).first()
|
||||||
if grant:
|
if grant:
|
||||||
return set(grant.permissions) if grant.permissions is not None else set(legacy_rights(write=grant.can_write, invite=grant.can_invite))
|
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:
|
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))
|
return set(legacy_rights(write=space.can_write_parent, invite=space.can_invite))
|
||||||
# Historical assigned-space access (parent) for legacy sub-users.
|
# Historical assigned-space access (parent) for legacy sub-users.
|
||||||
|
|||||||
154
backend/drive/test_space_reparent_and_access.py
Normal file
154
backend/drive/test_space_reparent_and_access.py
Normal 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())
|
||||||
@ -10,6 +10,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.contrib.auth.password_validation import validate_password
|
from django.contrib.auth.password_validation import validate_password
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction, IntegrityError
|
from django.db import transaction, IntegrityError
|
||||||
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
@ -396,6 +397,108 @@ def create_shared_space(request):
|
|||||||
return Response(space_info(space,request.user),status=201)
|
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'])
|
@api_view(['PATCH','DELETE'])
|
||||||
def edit_shared_space(request,space_id):
|
def edit_shared_space(request,space_id):
|
||||||
try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=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)
|
return error('Modification interdite.',403)
|
||||||
if request.method == 'DELETE':
|
if request.method == 'DELETE':
|
||||||
return delete_shared_space(request, space_id)
|
return delete_shared_space(request, space_id)
|
||||||
|
moved = None
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if 'display_name' in request.data:
|
# Prevent concurrent moves/creation or quota alterations from
|
||||||
title=valid_name(request.data['display_name'])
|
# observing an incomplete hierarchy.
|
||||||
if len(title)>120:return error('Nom limité à 120 caractères.')
|
list(DriveSpace.objects.select_for_update().order_by('pk'))
|
||||||
space.display_name=title
|
space = DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
|
||||||
space.save(update_fields=['display_name'])
|
if 'parent_space' in request.data:
|
||||||
|
moved = _relocate_shared_space(request.user, space, request.data['parent_space'])
|
||||||
if 'quota_bytes' in request.data:
|
if 'quota_bytes' in request.data:
|
||||||
_update_quota(space,request.data['quota_bytes'])
|
_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))
|
return Response(space_info(space,request.user))
|
||||||
|
|
||||||
|
|
||||||
@ -589,16 +709,22 @@ def admin_user_access(request, user_id):
|
|||||||
if is_admin(target):
|
if is_admin(target):
|
||||||
return error('Impossible de modifier les accès d’un administrateur.', 403)
|
return error('Impossible de modifier les accès d’un administrateur.', 403)
|
||||||
accesses = []
|
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'):
|
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})
|
if grant.space_id not in role_spaces:
|
||||||
for space in DriveSpace.objects.filter(user=target, private_owner__isnull=True):
|
accesses.append({'kind':'grant', 'space_id':grant.space_id,
|
||||||
accesses.append({'kind':'owner', 'space_id':space.pk, 'space_name':space.display_name})
|
'space_name':grant.space.display_name, 'permissions':[right for right in RIGHTS if right in space_rights(target, grant.space)]})
|
||||||
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})
|
return Response({'accesses': accesses})
|
||||||
|
|
||||||
|
|
||||||
@api_view(['DELETE'])
|
@api_view(['DELETE','PATCH'])
|
||||||
def admin_revoke_user_access(request, user_id, kind, space_id):
|
def admin_revoke_user_access(request, user_id, kind, space_id):
|
||||||
"""Revoke a space membership, legacy assignment, or managed-space grant.
|
"""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):
|
if is_admin(target):
|
||||||
return error('Impossible de modifier les accès d’un administrateur.', 403)
|
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)
|
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':
|
if kind == 'grant':
|
||||||
deleted, _ = SpaceGrant.objects.filter(space__in=qs, user=target).delete()
|
deleted, _ = SpaceGrant.objects.filter(space__in=qs, user=target).delete()
|
||||||
if not deleted:return error('Accès introuvable.', 404)
|
if not deleted:return error('Accès introuvable.', 404)
|
||||||
elif kind == 'owner':
|
elif kind == 'owner':
|
||||||
if not qs.filter(user=target).update(user=None):
|
if not qs.filter(user=target).update(user=None):
|
||||||
return error('Accès introuvable.', 404)
|
return error('Accès introuvable.', 404)
|
||||||
|
SpaceGrant.objects.filter(space_id=space_id,user=target).delete()
|
||||||
else:
|
else:
|
||||||
if not qs.filter(managed_by=target).update(managed_by=None):
|
if not qs.filter(managed_by=target).update(managed_by=None):
|
||||||
return error('Accès introuvable.', 404)
|
return error('Accès introuvable.', 404)
|
||||||
|
SpaceGrant.objects.filter(space_id=space_id,user=target).delete()
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -76,6 +76,7 @@ export const drive={
|
|||||||
adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}),
|
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),
|
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}/`),
|
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),
|
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),
|
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),
|
patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),
|
||||||
|
|||||||
@ -16,6 +16,8 @@ export default function SpaceSettingsDialog({controller: c}) {
|
|||||||
const [quota,setQuota] = useState('')
|
const [quota,setQuota] = useState('')
|
||||||
const [parent,setParent] = useState('')
|
const [parent,setParent] = useState('')
|
||||||
const [busy,setBusy] = useState(false)
|
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('')
|
const [localError,setLocalError] = useState('')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(manage ? current?.display_name || '' : '')
|
setName(manage ? current?.display_name || '' : '')
|
||||||
@ -30,9 +32,11 @@ export default function SpaceSettingsDialog({controller: c}) {
|
|||||||
try {
|
try {
|
||||||
if (manage && !current) throw new Error('Sous-espace introuvable. Actualise la page.')
|
if (manage && !current) throw new Error('Sous-espace introuvable. Actualise la page.')
|
||||||
const data={display_name:name.trim(),quota_bytes:toQuota(quota)}
|
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)})
|
const result=manage ? await drive.editSpace(current.id,data) : await drive.createSpace({...data,parent_space:Number(parent)})
|
||||||
await loadSpaces()
|
await loadSpaces()
|
||||||
if (!manage) switchSpace(result.id)
|
if (!manage) switchSpace(result.id)
|
||||||
|
if (manage && data.parent_space) await c.reloadStats()
|
||||||
setNote(manage ? 'Espace modifié.' : 'Espace créé.')
|
setNote(manage ? 'Espace modifié.' : 'Espace créé.')
|
||||||
setSpaceDialog(null)
|
setSpaceDialog(null)
|
||||||
} catch(exception) {setLocalError(exception?.response ? message(exception) : exception.message || message(exception))}
|
} 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">
|
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>)}
|
{managed.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)}
|
||||||
</select></div>}
|
</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>
|
<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>
|
<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} />
|
<QuotaField id="space-quota" value={quota} onChange={setQuota} disabled={busy} />
|
||||||
@ -68,3 +80,15 @@ export default function SpaceSettingsDialog({controller: c}) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
||||||
|
}
|
||||||
|
|||||||
@ -65,9 +65,13 @@ export function useDriveController(user) {
|
|||||||
useEffect(() => { loadSpaces() }, [loadSpaces])
|
useEffect(() => { loadSpaces() }, [loadSpaces])
|
||||||
|
|
||||||
const switchSpace = id => {
|
const switchSpace = id => {
|
||||||
++statsSequence.current
|
// Clicking the current space must not erase its statistics. No dependency
|
||||||
setStats(null)
|
// changes in that case, so the stats effect would not fetch them again.
|
||||||
setActiveSpaceId(id)
|
if (id !== activeSpaceId) {
|
||||||
|
++statsSequence.current
|
||||||
|
setStats(null)
|
||||||
|
setActiveSpaceId(id)
|
||||||
|
}
|
||||||
setView('files')
|
setView('files')
|
||||||
setParent(null)
|
setParent(null)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { drive, message } from '@/api/client'
|
import { drive, message } from '@/api/client'
|
||||||
|
import PermissionsEditor from '@/components/drive/PermissionsEditor'
|
||||||
|
|
||||||
export default function AdminUsersPage(){
|
export default function AdminUsersPage(){
|
||||||
const [query,setQuery] = useState('')
|
const [query,setQuery] = useState('')
|
||||||
@ -37,6 +38,14 @@ export default function AdminUsersPage(){
|
|||||||
catch(e){setAccessError(message(e))}
|
catch(e){setAccessError(message(e))}
|
||||||
finally{setAccessBusy(false)}
|
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=>{
|
const revokeAccess=async access=>{
|
||||||
if(!accessUser || !window.confirm(`Retirer l’accès à « ${access.space_name} » pour @${accessUser.username} ?`))return
|
if(!accessUser || !window.confirm(`Retirer l’accès à « ${access.space_name} » pour @${accessUser.username} ?`))return
|
||||||
setAccessBusy(true);setAccessError('')
|
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>}
|
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={!!accessUser} onOpenChange={open=>{if(!open&&!accessBusy)setAccessUser(null)}}>
|
<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>
|
<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>
|
</DialogHeader>
|
||||||
{accessError && <p role="alert" className="text-sm text-destructive">{accessError}</p>}
|
{accessError && <p role="alert" className="text-sm text-destructive">{accessError}</p>}
|
||||||
{accessBusy && <LoaderCircle className="size-5 animate-spin"/>}
|
{accessBusy && <LoaderCircle className="size-5 animate-spin"/>}
|
||||||
<div className="max-h-80 space-y-2 overflow-y-auto">
|
<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">
|
{accesses.map(access=><AccessPermissionsRow key={`${access.kind}-${access.space_id}`}
|
||||||
<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>
|
access={access} busy={accessBusy} save={permissions=>updateAccess(access,permissions)} revoke={()=>revokeAccess(access)}/>)}
|
||||||
<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>}
|
{!accessBusy && !accesses.length && <p className="text-sm text-muted-foreground">Aucun accès à un espace partagé.</p>}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@ -108,3 +115,18 @@ export default function AdminUsersPage(){
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</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>
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user