nickel
This commit is contained in:
parent
90004d4540
commit
e1473076e2
@ -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.
|
||||
|
||||
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.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)
|
||||
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user