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: 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.

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.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)

View File

@ -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),

View File

@ -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
}

View File

@ -65,9 +65,13 @@ export function useDriveController(user) {
useEffect(() => { loadSpaces() }, [loadSpaces]) useEffect(() => { loadSpaces() }, [loadSpaces])
const switchSpace = id => { const switchSpace = 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 ++statsSequence.current
setStats(null) setStats(null)
setActiveSpaceId(id) setActiveSpaceId(id)
}
setView('files') setView('files')
setParent(null) setParent(null)
setQuery('') setQuery('')

View File

@ -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>
}