From 7da4c7cd67b1d67878e4be9519d1da57e5ef6ce6 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 14 Oct 2026 16:35:14 +0200 Subject: [PATCH] nice --- backend/drive/admin.py | 18 +- .../migrations/0016_archived_drive_account.py | 18 ++ backend/drive/models.py | 6 + backend/drive/spaces.py | 45 +++-- backend/drive/test_protected_deletions.py | 121 ++++++++++++ backend/drive/urls.py | 1 + backend/drive/views_items.py | 2 +- backend/drive/views_spaces.py | 180 ++++++++++++++---- frontend/src/api/client.js | 1 + .../src/components/drive/ContextActions.jsx | 6 +- .../src/components/drive/DriveSidebar.jsx | 6 +- .../drive/dialogs/DeleteSpaceDialog.jsx | 6 +- frontend/src/pages/AdminUsersPage.jsx | 42 +++- 13 files changed, 370 insertions(+), 82 deletions(-) create mode 100644 backend/drive/migrations/0016_archived_drive_account.py create mode 100644 backend/drive/test_protected_deletions.py diff --git a/backend/drive/admin.py b/backend/drive/admin.py index c261fb9..a334d9e 100644 --- a/backend/drive/admin.py +++ b/backend/drive/admin.py @@ -1,6 +1,5 @@ """Administration of users and shared spaces, without access to personal files.""" from django.contrib import admin -from django.db.models import Q from .models import DriveSpace, SpaceInvitation, SpaceGrant @@ -17,22 +16,19 @@ class SpaceAdmin(admin.ModelAdmin): return base def get_queryset(self, request): - # Private spaces from other users never appear, even for a superuser. - return super().get_queryset(request).filter( - Q(private_owner__isnull=True) | Q(private_owner=request.user) - ) - - def _personal_other(self, request, obj): - return bool(obj and obj.private_owner_id and obj.private_owner_id != request.user.pk) + # The Django admin must never expose personal storage records. + return super().get_queryset(request).filter(private_owner__isnull=True) def has_view_permission(self, request, obj=None): - return not self._personal_other(request, obj) and super().has_view_permission(request, obj) + return (obj is None or not obj.private_owner_id) and super().has_view_permission(request, obj) def has_change_permission(self, request, obj=None): - return not self._personal_other(request, obj) and super().has_change_permission(request, obj) + return (obj is None or not obj.private_owner_id) and super().has_change_permission(request, obj) def has_delete_permission(self, request, obj=None): - return not self._personal_other(request, obj) and super().has_delete_permission(request, obj) + # All deletion goes through the guarded GoodDrive endpoint to avoid + # bypassing private-space preservation and on-disk cleanup. + return False @admin.register(SpaceInvitation) diff --git a/backend/drive/migrations/0016_archived_drive_account.py b/backend/drive/migrations/0016_archived_drive_account.py new file mode 100644 index 0000000..eb62825 --- /dev/null +++ b/backend/drive/migrations/0016_archived_drive_account.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [('drive', '0015_item_shares'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel( + name='ArchivedDriveAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('deleted_at', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, + related_name='drive_archive', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/drive/models.py b/backend/drive/models.py index 89e7603..59fdfd0 100644 --- a/backend/drive/models.py +++ b/backend/drive/models.py @@ -122,3 +122,9 @@ class PublicItemLink(models.Model): created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+') created_at = models.DateTimeField(auto_now_add=True) revoked_at = models.DateTimeField(null=True, blank=True) + + +class ArchivedDriveAccount(models.Model): + """Account removed from the app while its immutable private space survives.""" + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_archive') + deleted_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/drive/spaces.py b/backend/drive/spaces.py index a5c6023..565020e 100644 --- a/backend/drive/spaces.py +++ b/backend/drive/spaces.py @@ -14,28 +14,24 @@ def is_admin(user): def ensure_space(user): - """Return an owned space, or the existing space granted to an invitee.""" + """Return an accessible shared space, or None if access was revoked. + + Never recreate a deleted assigned space or grant access to the root when + an administrator removes an account's last shared space. + """ obj = DriveSpace.objects.filter(user=user, private_owner__isnull=True).first() if obj: - (Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True) return obj - # Invitation-only users access an existing space through a SpaceGrant. - # They must never create another numbered shared directory implicitly. - grant = SpaceGrant.objects.filter(user=user).select_related('space').order_by('created_at','pk').first() + grant = SpaceGrant.objects.filter(user=user).select_related('space').order_by('created_at', 'pk').first() if grant: return grant.space + if not is_admin(user): + return None with transaction.atomic(): - obj = DriveSpace.objects.select_for_update().filter(user=user, private_owner__isnull=True).first() - if obj: - return obj root = DriveSpace.objects.filter(parent__isnull=True, private_owner__isnull=True).order_by('id').first() if root is None: - if not is_admin(user): - raise PermissionError('Un administrateur doit initialiser le Drive.') - obj = DriveSpace.objects.create(user=user, relative_path='', display_name='Racine') - else: - obj = create_space(user, root, user.username) - return obj + root = DriveSpace.objects.create(user=user, relative_path='', display_name='Racine') + return root def _make_numbered_space(parent, **fields): @@ -105,6 +101,10 @@ def ensure_private_space(user): if obj: return obj assigned = ensure_space(user) + if assigned is None: + assigned = DriveSpace.objects.filter(parent__isnull=True, private_owner__isnull=True).first() + if assigned is None: + raise PermissionError('Stockage principal non initialisé.') # The 50-Go cap is only a limit on private uploads, not a reservation. return _make_numbered_space(assigned, private_owner=user, display_name='Mes fichiers') @@ -120,6 +120,8 @@ def _grant(user, space): def can_read_space(user, space): + if space is None: + return False if not user.is_authenticated or not user.is_active: return False # Personal files are never subject to administrative or inherited access. @@ -128,14 +130,16 @@ def can_read_space(user, space): if is_admin(user) or space.managed_by_id == user.pk: return True own = ensure_space(user) - if own.pk == space.pk and own.user_id == user.pk: + if own and own.pk == space.pk and own.user_id == user.pk: return True - if own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent: + if own and own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent: return True return _grant(user, space) is not None def can_write_space(user, space): + if space is None: + return False if not user.is_authenticated or not user.is_active: return False # Personal files are never subject to administrative or inherited access. @@ -144,15 +148,17 @@ def can_write_space(user, space): if is_admin(user) or space.managed_by_id == user.pk: return True own = ensure_space(user) - if own.pk == space.pk and own.user_id == user.pk: + if own and own.pk == space.pk and own.user_id == user.pk: return True - if own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent and own.can_write_parent: + if own and own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent and own.can_write_parent: return True grant = _grant(user, space) return bool(grant and grant.can_write) def can_manage_space(user, space): + if space is None: + return False if not user.is_authenticated or not user.is_active or space.private_owner_id: return False if is_admin(user) or space.managed_by_id == user.pk: @@ -187,7 +193,8 @@ def space_info(space, user): 'is_private': bool(space.private_owner_id), 'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space), 'can_invite': can_manage_space(user, space), - 'can_edit': is_admin(user) or space.managed_by_id == user.pk or (space.user_id == user.pk and can_manage_space(user, space)), + 'can_edit': not space.private_owner_id and (is_admin(user) or space.managed_by_id == user.pk or (space.user_id == user.pk and can_manage_space(user, space))), + 'can_delete': not space.private_owner_id and (is_admin(user) or (space.parent_id is not None and not space.user_id and space.managed_by_id == user.pk)), 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True, **quota_state(space), 'can_view_parent': space.can_view_parent, 'can_write_parent': space.can_write_parent, diff --git a/backend/drive/test_protected_deletions.py b/backend/drive/test_protected_deletions.py new file mode 100644 index 0000000..dd99481 --- /dev/null +++ b/backend/drive/test_protected_deletions.py @@ -0,0 +1,121 @@ +"""Deletion contract: admin may remove shared spaces, never private spaces. + +Run: USE_SQLITE=true python manage.py test drive.test_protected_deletions +""" +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, ArchivedDriveAccount +from .spaces import ensure_space, ensure_private_space, create_space + + +class ProtectedDeletionTests(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('test-admin', 'admin@example.org', 'VeryStrongPass123!') + self.alice = User.objects.create_user('test-alice', password='VeryStrongPass123!') + self.root = ensure_space(self.admin) + self.admin_private = ensure_private_space(self.admin) + self.assigned = create_space(self.alice, self.root, 'Alice partagé') + self.private = ensure_private_space(self.alice) + self.client = APIClient() + self.client.force_authenticate(user=self.admin) + + def private_file(self): + item = DriveItem.objects.create(owner=self.alice, space=self.private, + name='mon-secret.txt', storage_key=str(uuid.uuid4()), + storage_location=f'{self.private.relative_path}/mon-secret.txt', size=6) + path = self.storage / item.storage_location + path.write_bytes(b'secret') + return item, path + + def test_admin_cannot_browse_or_delete_any_other_private_space(self): + item, path = self.private_file() + result = self.client.get('/api/drive/spaces/') + self.assertEqual(result.status_code, 200, result.data) + self.assertNotIn(self.private.pk, [s['id'] for s in result.data['spaces']]) + self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code, 403) + self.assertEqual(self.client.get('/api/drive/stats/', {'space':self.private.pk}).status_code, 403) + self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/link/').status_code, 403) + self.assertEqual(self.client.delete(f'/api/drive/spaces/{self.private.pk}/', + {'confirm_name':self.private.display_name}, format='json').status_code, 403) + self.client.force_authenticate(user=self.alice) + self.assertEqual(self.client.delete(f'/api/drive/spaces/{self.private.pk}/', + {'confirm_name':self.private.display_name}, format='json').status_code, 403) + self.assertTrue(path.is_file()) + + def test_admin_can_delete_assigned_shared_space_without_private_loss(self): + secret, path = self.private_file() + shared_path = self.storage / self.assigned.relative_path + self.assertTrue(shared_path.is_dir()) + with self.captureOnCommitCallbacks(execute=True): + res = self.client.delete(f'/api/drive/spaces/{self.assigned.pk}/', + {'confirm_name':self.assigned.display_name}, format='json') + self.assertEqual(res.status_code, 200, res.data) + self.assertFalse(DriveSpace.objects.filter(pk=self.assigned.pk).exists()) + self.assertFalse(shared_path.exists()) + self.private.refresh_from_db() + self.assertEqual(self.private.parent_id, self.root.pk) + self.assertTrue(path.exists()) + self.assertTrue(DriveItem.objects.filter(pk=secret.pk).exists()) + self.client.force_authenticate(user=self.alice) + self.assertIsNone(ensure_space(self.alice)) + self.assertIsNone(self.client.get('/api/auth/me/').data['space_id']) + self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code, 200) + self.assertEqual(self.client.get('/api/drive/items/', {'space':self.root.pk}).status_code, 403) + + def test_admin_root_reset_keeps_root_and_private_files(self): + item, private_path = self.private_file() + root_folder = DriveItem.objects.create(owner=self.admin, space=self.root, + name='public-folder', is_folder=True) + public_path = self.storage / 'public-folder' + public_path.mkdir() + shared_path = self.storage / self.assigned.relative_path + self.assertTrue(shared_path.is_dir()) + with self.captureOnCommitCallbacks(execute=True): + res = self.client.delete(f'/api/drive/spaces/{self.root.pk}/', + {'confirm_name':self.root.display_name}, format='json') + self.assertEqual(res.status_code, 200, res.data) + self.assertTrue(res.data['root_reset']) + self.assertTrue(DriveSpace.objects.filter(pk=self.root.pk).exists()) + self.assertFalse(DriveSpace.objects.filter(pk=self.assigned.pk).exists()) + self.assertFalse(DriveItem.objects.filter(pk=root_folder.pk).exists()) + self.assertFalse(shared_path.exists()) + self.assertFalse(public_path.exists()) + self.assertTrue(private_path.exists()) + self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists()) + self.assertTrue((self.storage/'privates').is_dir()) + + def test_user_deletion_anonymizes_account_but_keeps_private(self): + item, path = self.private_file() + self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/', + {'confirm_username':self.admin.username}, format='json').status_code, 403) + self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', + {'confirm_username':'wrong'}, format='json').status_code, 400) + self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', + {'confirm_username':self.alice.username}, format='json').status_code, 204) + self.alice.refresh_from_db() + self.assertFalse(self.alice.is_active) + self.assertFalse(self.alice.has_usable_password()) + self.assertTrue(ArchivedDriveAccount.objects.filter(user=self.alice).exists()) + self.assertNotIn(self.alice.pk, [u['id'] for u in self.client.get('/api/drive/admin/users/').data['users']]) + self.assertTrue(path.exists()) + self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk, private_owner=self.alice).exists()) + self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists()) + self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', + {'confirm_username':self.alice.username}, format='json').status_code, 404) + + def test_non_admin_cannot_delete_other_users(self): + self.client.force_authenticate(user=self.alice) + self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/', + {'confirm_username':self.admin.username}, format='json').status_code, 403) diff --git a/backend/drive/urls.py b/backend/drive/urls.py index f0be772..a440edf 100644 --- a/backend/drive/urls.py +++ b/backend/drive/urls.py @@ -4,6 +4,7 @@ from . import views_spaces, views_shares urlpatterns=[ path('spaces/',views_spaces.spaces), path('admin/users/',views_spaces.admin_users), + path('admin/users//',views_spaces.admin_delete_user), path('shares/users/search/',views_shares.search_share_users), path('items//shares/',views_shares.manage_item_shares), path('items//shares//',views_shares.remove_item_share), diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py index 3190463..6e81fe6 100644 --- a/backend/drive/views_items.py +++ b/backend/drive/views_items.py @@ -21,7 +21,7 @@ def me(request): return Response({'id':request.user.pk,'username':request.user.username, 'is_admin':is_admin(request.user),'first_name':request.user.first_name, 'last_name':request.user.last_name,'private_space_id':private.pk, - 'space_id':space.pk,'can_invite':space.can_invite or is_admin(request.user)}) + 'space_id':space.pk if space else None,'can_invite':bool(space and space.can_invite) or is_admin(request.user)}) @api_view(['GET']) diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py index 3a535e6..d6af785 100644 --- a/backend/drive/views_spaces.py +++ b/backend/drive/views_spaces.py @@ -15,7 +15,8 @@ from rest_framework.decorators import api_view, permission_classes, throttle_cla from rest_framework.permissions import AllowAny from rest_framework.throttling import AnonRateThrottle from rest_framework.response import Response -from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession +from .models import (DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession, + ItemGrant, PublicItemLink, ArchivedDriveAccount) from .services import error, valid_name from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_write_space, can_manage_space from .quotas import (parse_quota, locked_quota_tree, @@ -57,20 +58,21 @@ def spaces(request): if is_admin(request.user): permitted=[private, *qs.filter(private_owner__isnull=True)] else: - permitted=[private,own] - if own.user_id == request.user.pk and own.parent_id and own.can_view_parent and not own.parent.private_owner_id: + permitted=[private] + ([own] if own else []) + if own and own.user_id == request.user.pk and own.parent_id and own.can_view_parent and not own.parent.private_owner_id: permitted.append(own.parent) permitted.extend(space for space in qs.filter(access_grants__user=request.user) if not space.private_owner_id) permitted.extend(qs.filter(managed_by=request.user,private_owner__isnull=True)) permitted=list({space.pk:space for space in permitted}.values()) - return Response({'mine':private.pk, 'assigned':own.pk, 'private':private.pk, + return Response({'mine':private.pk, 'assigned':own.pk if own else None, 'private':private.pk, 'spaces':[space_info(space,request.user) for space in permitted]}) @api_view(['PATCH']) def rename_my_space(request): own=my_space(request.user) - if own.user_id != request.user.pk and not is_admin(request.user): + if own is None:return error('Aucun espace attribué.',404) + if own.private_owner_id or (own.user_id != request.user.pk and not is_admin(request.user)): return error('Impossible de renommer un espace attribué à un autre compte.',403) try: title=valid_name(request.data.get('display_name')) @@ -391,16 +393,16 @@ _logger = logging.getLogger(__name__) def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids): - """Executed only AFTER commit: keep SQL changes atomic before touching disk.""" + """Remove only the shared directory after the DB transaction commits.""" root = Path(settings.DRIVE_STORAGE_ROOT).resolve() folder = (root / relative_path).resolve() - if folder == root or not folder.is_relative_to(root): + private_root = root / 'privates' + if folder == root or not folder.is_relative_to(root) or folder.is_relative_to(private_root): _logger.error('Invalid space path for disk cleanup: %s', relative_path) return - # Old files may predate the numbered storage directories. for location in legacy_locations: path = (root / location).resolve() - if path.is_relative_to(root) and not path.is_relative_to(folder): + if path.is_relative_to(root) and not path.is_relative_to(private_root) and not path.is_relative_to(folder): try: path.unlink(missing_ok=True) except OSError: @@ -410,19 +412,47 @@ def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids): shutil.rmtree(folder) except OSError: _logger.exception('Unable to remove deleted space directory: %s', folder) + _remove_upload_parts(temp_ids) + + +def _remove_upload_parts(upload_ids): temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve() - for upload_id in temp_ids: + for upload_id in upload_ids: try: (temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True) except OSError: _logger.exception('Unable to remove canceled upload %s', upload_id) -def delete_shared_space(request, space_id): - """Permanently delete a manually managed space and its independent descendants. +def _clean_root_shared_files(file_locations, folder_locations, upload_ids): + """The root DB row and physical storage directory must survive a reset.""" + root = Path(settings.DRIVE_STORAGE_ROOT).resolve() + private_root = root / 'privates' + # Remove only tracked data, not the private directories or unrelated host files. + for location in file_locations: + path = (root / location).resolve() + if path.is_relative_to(root) and not path.is_relative_to(private_root) and path.is_file(): + try: + path.unlink() + except OSError: + _logger.exception('Cannot delete root file: %s', path) + for location in sorted(folder_locations, key=lambda x: len(x), reverse=True): + path = (root / location).resolve() + if path != root and path.is_relative_to(root) and not path.is_relative_to(private_root): + try: + if path.is_dir(): + # Only remove empty dirs: unknown files are never destroyed. + path.rmdir() + except OSError: + pass + _remove_upload_parts(upload_ids) - Never delete root, assigned account spaces, or ANY private space, including a - private descendant. All cleanup is committed before disk deletion starts. + +def delete_shared_space(request, space_id): + """Admins can remove all *shared* spaces; private spaces are immutable. + + Deleting the root clears its shared contents but preserves its database + anchor and physical storage directory for all private spaces. """ confirm_name = request.data.get('confirm_name') if not isinstance(confirm_name, str): @@ -432,35 +462,69 @@ def delete_shared_space(request, space_id): space = DriveSpace.objects.select_for_update().select_related('parent').get(pk=space_id) except DriveSpace.DoesNotExist: return error('Espace introuvable.', 404) - if not space.parent_id or space.user_id or space.private_owner_id or not space.managed_by_id: - return error('Les espaces personnels, attribués et racine ne peuvent pas être supprimés.', 403) - if not manager(request.user, space): + if space.private_owner_id: + return error('Un espace privé ne peut jamais être supprimé.', 403) + root = DriveSpace.objects.select_for_update().filter( + parent__isnull=True, private_owner__isnull=True).first() + if root is None: + return error('Espace racine introuvable.', 409) + admin = is_admin(request.user) + if not admin and (not space.parent_id or space.user_id or space.managed_by_id != request.user.pk): return error('Suppression interdite.', 403) if confirm_name != space.display_name: return error('Le nom de confirmation ne correspond pas au nom de l’espace.') - # Also lock the parent: creation and deletion of siblings are serialized. - list(DriveSpace.objects.select_for_update().filter(pk=space.parent_id)) - descendants = list(DriveSpace.objects.select_for_update().filter( - Q(relative_path=space.relative_path) | - Q(relative_path__startswith=space.relative_path + '/') - ).order_by('-relative_path')) - if any(child.user_id or child.private_owner_id or not child.managed_by_id for child in descendants): - return error('Cet espace contient un espace attribué ou privé. Réorganise les sous-utilisateurs avant de le supprimer.', 409) - ids = [child.pk for child in descendants] - files = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list( - 'storage_location', 'storage_key')) - legacy_locations = [location or f'{key[:2]}/{key}' - for location, key in files if key and not location] + # Lock entire hierarchy to make re-parenting deterministic. + all_spaces = list(DriveSpace.objects.select_for_update().order_by('pk')) + # Do not remove any shared directory while a legacy private directory + # still lives outside storage/privates. Migrate its physical layout first. + if any(node.private_owner_id and not node.relative_path.startswith('privates/') + for node in all_spaces): + return error('Déplace d’abord les espaces privés vers storage/privates avant toute suppression.', 409) + is_root_reset = space.pk == root.pk + if is_root_reset and not admin: + return error('Suppression interdite.', 403) + prefix = space.relative_path + '/' if space.relative_path else '' + targets = [node for node in all_spaces if not node.private_owner_id and ( + is_root_reset or node.pk == space.pk or node.relative_path.startswith(prefix))] + to_delete = [node for node in targets if not is_root_reset or node.pk != root.pk] + ids = [node.pk for node in targets] + # Private spaces retain their location under storage/privates and are + # only relinked logically to root, so no private data is ever deleted. + for private in all_spaces: + if private.private_owner_id and private.parent_id in ids and private.parent_id != root.pk: + private.parent = root + private.save(update_fields=['parent']) + root_items = list(DriveItem.objects.filter(space=root)) if is_root_reset else [] + root_files = [(i.storage_location or f'{i.storage_key[:2]}/{i.storage_key}') + for i in root_items if not i.is_folder and i.storage_key] + # Directories can be represented in SQL without an on-disk file path. + root_folders = [] + if is_root_reset: + from .storage_layout import file_location + for item in root_items: + if item.is_folder: + root_folders.append(file_location(root, item.parent, item.name)) + file_records = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list( + 'space_id', 'storage_location', 'storage_key')) + legacy = [location or f'{key[:2]}/{key}' for sid, location, key in file_records + if key and not location and (not is_root_reset or sid != root.pk)] upload_ids = list(UploadSession.objects.select_for_update().filter( space_id__in=ids).values_list('pk', flat=True)) - # Children of DriveItem use CASCADE; item ownership and other accounts - # are left intact, only metadata from these owned spaces is deleted. DriveItem.objects.filter(space_id__in=ids).delete() - for child in descendants: - child.delete() # bottom-up, as DriveSpace.parent uses PROTECT - transaction.on_commit(lambda: _remove_deleted_space_files( - space.relative_path, legacy_locations, upload_ids)) - return Response({'deleted_space_ids': ids}) + # Foreign keys to these spaces cascade (grants, invitations, uploads). + for node in sorted(to_delete, key=lambda x: len(x.relative_path), reverse=True): + node.delete() + if is_root_reset: + # Clean each shared child directory, but NEVER storage/privates. + top_dirs = [node.relative_path for node in to_delete + if node.parent_id == root.pk] + transaction.on_commit(lambda: ( + [_remove_deleted_space_files(location, [], []) for location in top_dirs], + _clean_root_shared_files(root_files + legacy, root_folders, upload_ids))) + else: + transaction.on_commit(lambda: _remove_deleted_space_files( + space.relative_path, legacy, upload_ids)) + return Response({'deleted_space_ids': ids, 'root_reset': is_root_reset}) @api_view(['GET']) @@ -468,7 +532,7 @@ def admin_users(request): if not is_admin(request.user):return error('Accès réservé aux administrateurs.',403) query = str(request.query_params.get('q','')).strip()[:100] User = get_user_model() - qs = User.objects.order_by('username') + qs = User.objects.filter(drive_archive__isnull=True).order_by('username') if query: qs = qs.filter(Q(username__icontains=query)|Q(first_name__icontains=query)|Q(last_name__icontains=query)|Q(email__icontains=query)) result=[] @@ -482,3 +546,43 @@ def admin_users(request): 'is_admin':is_admin(user), 'space_name':assigned.display_name if assigned else None, 'date_joined':user.date_joined.isoformat()}) return Response({'users':result[:200], 'limited':len(result)>200}) + + +@api_view(['DELETE']) +def admin_delete_user(request, user_id): + """Remove account access without deleting the non-deletable private space. + + The user row remains as an anonymized, inactive data owner so PROTECT + relations on private files and the private space never cascade away. + """ + if not is_admin(request.user): + return error('Accès réservé aux administrateurs.', 403) + User = get_user_model() + with transaction.atomic(): + try: + target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True) + except User.DoesNotExist: + return error('Utilisateur introuvable.', 404) + if target.pk == request.user.pk or is_admin(target): + return error('Impossible de supprimer un administrateur.', 403) + if request.data.get('confirm_username') != target.username: + return error('Saisis le nom d’utilisateur pour confirmer.') + now = timezone.now() + SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now) + PublicItemLink.objects.filter(Q(created_by=target) | Q(item__space__private_owner=target), + revoked_at__isnull=True).update(revoked_at=now) + ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete() + SpaceGrant.objects.filter(user=target).delete() + unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk', flat=True)) + UploadSession.objects.filter(owner=target).delete() + if unfinished: + transaction.on_commit(lambda: _remove_upload_parts(unfinished)) + target.is_active = False + target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}' + target.first_name = '' + target.last_name = '' + target.email = '' + target.set_unusable_password() + target.save(update_fields=['is_active', 'username', 'first_name', 'last_name', 'email', 'password']) + ArchivedDriveAccount.objects.create(user=target) + return Response(status=204) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index f69904c..500fbf6 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -66,6 +66,7 @@ export const drive={ revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`), publicBrowse:(token,parent)=>api.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data), adminUsers:q=>api.get('/drive/admin/users/',{params:{q}}).then(r=>r.data), + adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}), 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), diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx index 813cb08..a17043e 100644 --- a/frontend/src/components/drive/ContextActions.jsx +++ b/frontend/src/components/drive/ContextActions.jsx @@ -20,10 +20,10 @@ export const fileOptions = (item, trash) => { ] } -export const spaceOptions = (space) => space?.can_invite ? [ +export const spaceOptions = (space) => (space?.can_invite || space?.can_delete) ? [ ...(space.can_edit ? [{id:'manage',title:'Paramètres de l’espace',icon:Settings2}] : []), - {id:'members',title:'Accès et invitations',icon:Users}, - ...(space.has_account || space.is_private || !space.parent_id ? [] : [{id:'delete',title:'Supprimer l’espace',icon:Trash2,danger:true}]), + ...(space.can_invite ? [{id:'members',title:'Accès et invitations',icon:Users}] : []), + ...(space.can_delete ? [{id:'delete',title:space.parent_id ? 'Supprimer l’espace' : 'Vider la racine',icon:Trash2,danger:true}] : []), ] : [] /** Keyboard-accessible viewport-clamped menu used on desktop right-click and mobile ···. */ diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index 989250b..48622e1 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -13,7 +13,7 @@ export default function DriveSidebar({ controller, user, onLogout }) { const [spaceMenu,setSpaceMenu] = useState(null) const closeSpaceMenu=useCallback(()=>setSpaceMenu(null),[]) useEffect(()=>{ - if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && space.can_invite)) closeSpaceMenu() + if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && (space.can_invite || space.can_delete))) closeSpaceMenu() },[spaces,spaceMenu,closeSpaceMenu]) const showSpaceMenu=(space,x,y)=>{ const options=spaceOptions(space) @@ -88,13 +88,13 @@ export default function DriveSidebar({ controller, user, onLogout }) {
{shared.map(space =>
{if(space.can_invite){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}> + onContextMenu={event=>{if(space.can_invite||space.can_delete){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}> - {space.can_invite && }
)} diff --git a/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx b/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx index fe0990a..1f5012d 100644 --- a/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx +++ b/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx @@ -33,7 +33,7 @@ export default function DeleteSpaceDialog({controller: c}) { } setSpaceDialog(null) await loadSpaces() - setNote('Espace supprimé définitivement.') + setNote(response.root_reset ? 'Contenu partagé de la racine supprimé.' : 'Espace supprimé définitivement.') } catch (exception) { const detail = message(exception) setLocalError(detail) @@ -46,12 +46,12 @@ export default function DeleteSpaceDialog({controller: c}) { Supprimer l’espace - L’espace « {space?.display_name} », ses fichiers, sa corbeille, ses transferts et ses sous-espaces autonomes seront supprimés définitivement. + {space?.parent_id ? `L’espace « ${space.display_name} » et ses sous-espaces partagés seront supprimés.` : 'Tous les fichiers et sous-espaces partagés de la racine seront supprimés. La racine restera en place.'}
-

Action irréversible. Si l’espace contient des sous-utilisateurs avec leur propre compte, la suppression sera bloquée pour protéger leurs données privées.

+

Action irréversible. Les espaces privés et leurs fichiers seront conservés.

diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index a1885c2..c91e34b 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -1,6 +1,8 @@ -import { useEffect, useState } from 'react' -import { Users, LoaderCircle } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { Users, LoaderCircle, Trash2 } from 'lucide-react' import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { drive, message } from '@/api/client' export default function AdminUsersPage(){ @@ -8,6 +10,12 @@ export default function AdminUsersPage(){ const [record,setRecord] = useState({users:[]}) const [busy,setBusy] = useState(true) const [error,setError] = useState('') + const [selected,setSelected] = useState(null) + const [confirmation,setConfirmation] = useState('') + const [deleting,setDeleting] = useState(false) + const [deleteError,setDeleteError] = useState('') + const [revision,setRevision] = useState(0) + const refresh=useCallback(()=>setRevision(n=>n+1),[]) useEffect(()=>{ let active=true const timer=setTimeout(async()=>{ @@ -17,22 +25,48 @@ export default function AdminUsersPage(){ finally{if(active)setBusy(false)} },query?300:0) return ()=>{active=false;clearTimeout(timer)} - },[query]) + },[query,revision]) + const close=()=>{if(!deleting){setSelected(null);setConfirmation('');setDeleteError('')}} + const remove=async(event)=>{ + event.preventDefault() + if(!selected||deleting||confirmation!==selected.username)return + setDeleting(true);setDeleteError('') + try{await drive.adminDeleteUser(selected.id,confirmation);setSelected(null);setConfirmation('');refresh()} + catch(e){setDeleteError(message(e))} + finally{setDeleting(false)} + } return

Utilisateurs

setQuery(e.target.value)}/> {error &&

{error}

} {busy?:
- +
UtilisateurIdentifiantE-mailEspace attribuéStatut
{record.users.map(user=> + )}
UtilisateurIdentifiantE-mailEspace attribuéStatutActions
{[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'} @{user.username}{user.email||'—'}{user.space_name||'—'} {user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''}{!user.is_admin && }
{!record.users.length &&

Aucun utilisateur.

}
} {record.limited &&

200 résultats affichés. Précise la recherche.

}
+ {if(!open)close()}}> + + Supprimer l’utilisateur + Le compte sera désactivé et retiré de la liste. Son espace privé sera conservé. + + + + {deleteError &&

{deleteError}

} + + + + +
+
}