From d94a36d9a72bf59cf5033c2173658f152e75155d Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 14 Oct 2026 18:09:57 +0200 Subject: [PATCH] parfait --- .../management/commands/cleanup_trash.py | 39 +++++ backend/drive/permissions.py | 24 +++- .../test_share_permissions_and_retention.py | 133 ++++++++++++++++++ backend/drive/trash_cleanup.py | 46 ++++++ backend/drive/urls.py | 1 + backend/drive/views_items.py | 45 +++--- backend/drive/views_shares.py | 26 +++- backend/entrypoint.sh | 7 + frontend/src/api/client.js | 1 + .../src/components/drive/ContextActions.jsx | 4 +- .../src/components/drive/DriveExplorer.jsx | 4 +- .../components/drive/PermissionsEditor.jsx | 39 ++++- .../drive/dialogs/ItemActionsDialog.jsx | 5 +- .../drive/dialogs/ShareItemDialog.jsx | 8 +- frontend/src/hooks/useDriveController.js | 2 + start.sh | 14 +- 16 files changed, 349 insertions(+), 49 deletions(-) create mode 100644 backend/drive/management/commands/cleanup_trash.py create mode 100644 backend/drive/test_share_permissions_and_retention.py create mode 100644 backend/drive/trash_cleanup.py diff --git a/backend/drive/management/commands/cleanup_trash.py b/backend/drive/management/commands/cleanup_trash.py new file mode 100644 index 0000000..ebdd948 --- /dev/null +++ b/backend/drive/management/commands/cleanup_trash.py @@ -0,0 +1,39 @@ +"""Purge items trashed at least 5 days ago (run by the server hourly).""" +from datetime import timedelta +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from drive.models import DriveItem +from drive.trash_cleanup import delete_trashed_item, has_active_uploads_in_tree + + +class Command(BaseCommand): + help = 'Supprime définitivement les éléments dans la corbeille depuis 5 jours.' + + def add_arguments(self, parser): + parser.add_argument('--days', type=int, default=5) + parser.add_argument('--dry-run', action='store_true') + + def handle(self, *args, **options): + days = options['days'] + if days < 1: + raise CommandError('Le délai doit être supérieur ou égal à 1 jour.') + limit = timezone.now() - timedelta(days=days) + purged = skipped = 0 + # Fetch IDs first: cascading parent deletion can remove children. + ids = list(DriveItem.objects.filter(trashed_at__lte=limit).order_by('trashed_at').values_list('pk', flat=True)) + for pk in ids: + item = DriveItem.objects.filter(pk=pk).select_related('space','parent').first() + if item is None: + continue + try: + if has_active_uploads_in_tree(item): + skipped += 1 + continue + if not options['dry_run']: + delete_trashed_item(item) + purged += 1 + except (OSError, ValueError) as exc: + skipped += 1 + self.stderr.write(f'Élément {pk} conservé : {exc}') + verb = 'à supprimer' if options['dry_run'] else 'supprimé(s)' + self.stdout.write(f'{purged} élément(s) {verb}, {skipped} ignoré(s).') diff --git a/backend/drive/permissions.py b/backend/drive/permissions.py index df6acf1..f939c59 100644 --- a/backend/drive/permissions.py +++ b/backend/drive/permissions.py @@ -2,6 +2,28 @@ from .models import ItemGrant, SpaceGrant RIGHTS = ('view', 'download', 'add', 'modify', 'delete', 'share', 'manage_access', 'create_spaces', 'manage_space') +# A file/folder share never delegates space administration or deletion. +ITEM_SHARE_RIGHTS = ('view', 'download', 'modify', 'add') + + +def available_item_share_rights(item): + return tuple(right for right in ITEM_SHARE_RIGHTS if right != 'add' or item.is_folder) + + +def normalized_item_share(value, item): + rights = normalized(value) + if not set(rights).issubset(available_item_share_rights(item)): + raise ValueError('Ce droit ne peut pas être attribué sur ce partage.') + return rights + + +def assert_item_delegation(issuer_rights, desired, item): + rights = normalized_item_share(desired, item) + if not set(rights).issubset(set(issuer_rights)): + raise ValueError('Tu ne peux pas attribuer des permissions supérieures aux tiennes.') + return rights + + PRESETS = { 'reader': ('view', 'download'), 'contributor': ('view', 'download', 'add', 'modify'), @@ -93,7 +115,7 @@ def item_rights(user, item): seen.add(current.pk) grant = ItemGrant.objects.filter(user=user, item=current).first() if grant: - return set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader']) + return (set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader'])) & set(available_item_share_rights(current)) current = current.parent return set() diff --git a/backend/drive/test_share_permissions_and_retention.py b/backend/drive/test_share_permissions_and_retention.py new file mode 100644 index 0000000..2bf1454 --- /dev/null +++ b/backend/drive/test_share_permissions_and_retention.py @@ -0,0 +1,133 @@ +"""Tests: USE_SQLITE=true python manage.py test drive.test_share_permissions_and_retention""" +import tempfile +from datetime import timedelta +from pathlib import Path +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient +from .models import DriveItem, DriveSpace, ItemGrant, UploadSession +from .permissions import item_rights +from .spaces import ensure_space, ensure_private_space + + +class ShareAndTrashTests(TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + cfg = override_settings(DRIVE_STORAGE_ROOT=self.root, + FILE_UPLOAD_TEMP_DIR=str(self.root/'.uploads')) + cfg.enable() + self.addCleanup(cfg.disable) + User = get_user_model() + self.owner = User.objects.create_user('share-owner',password='abc1234567890') + self.recipient = User.objects.create_user('share-recipient',password='abc1234567890') + self.space = DriveSpace.objects.create( + user=self.owner,relative_path='',display_name='Partagé') + self.private = ensure_private_space(self.owner) + self.client = APIClient() + self.client.force_authenticate(user=self.owner) + + def make_item(self, name, folder=False, parent=None, space=None): + space = space or self.private + location = '/'.join(filter(None,(space.relative_path, parent.name if parent else '', name))) + entry = DriveItem.objects.create(owner=self.owner, space=space, parent=parent, + name=name,is_folder=folder, storage_key='' if folder else 'f'*36, + storage_location='' if folder else location,size=0 if folder else 4) + path = self.root / location + path.parent.mkdir(parents=True, exist_ok=True) + if folder:path.mkdir(exist_ok=True) + else:path.write_bytes(b'data') + return entry, path + + def share(self, item, permissions): + return self.client.post(f'/api/drive/items/{item.pk}/shares/', + {'user_id':self.recipient.pk,'permissions':permissions},format='json') + + def test_file_shares_only_allow_view_download_modify(self): + file, _ = self.make_item('photo.jpg') + for right in ('add','delete','share','manage_access','create_spaces','manage_space'): + result = self.share(file,['view',right]) + self.assertEqual(result.status_code,403,(right,result.data)) + success = self.share(file,['view','modify']) + self.assertEqual(success.status_code,201,success.data) + self.client.force_authenticate(user=self.recipient) + self.assertIn('modify',item_rights(self.recipient,file)) + self.assertNotIn('delete',item_rights(self.recipient,file)) + self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/trash/').status_code,403) + response=self.client.patch(f'/api/drive/items/{file.pk}/',{'name':'new.jpg'},format='json') + self.assertEqual(response.status_code,200,response.data) + self.assertEqual(self.client.patch(f'/api/drive/items/{file.pk}/',{'parent':None},format='json').status_code,200) + self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/public-links/').status_code,403) + + def test_folder_add_inherited_but_no_delete_or_sharing(self): + folder, _ = self.make_item('docs',folder=True) + child, _ = self.make_item('a.txt',parent=folder) + response=self.share(folder,['view','download','modify','add']) + self.assertEqual(response.status_code,201,response.data) + self.client.force_authenticate(user=self.recipient) + self.assertIn('add',item_rights(self.recipient,folder)) + self.assertIn('modify',item_rights(self.recipient,child)) + self.assertNotIn('share',item_rights(self.recipient,child)) + self.assertEqual(self.client.post(f'/api/drive/folders/',{ + 'parent':str(folder.pk),'space':self.private.pk,'name':'new'},format='json').status_code,201) + self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/trash/').status_code,403) + self.assertEqual(self.client.patch(f'/api/drive/items/{folder.pk}/',{'parent':None},format='json').status_code,200) + + def test_old_unsafe_share_rights_are_clamped_and_removing_share_preserves_file(self): + file,path=self.make_item('old.txt') + grant=ItemGrant.objects.create(item=file,user=self.recipient,created_by=self.owner, + permissions=['view','download','add','delete','share','manage_access','manage_space']) + self.assertEqual(item_rights(self.recipient,file),{'view','download'}) + self.client.force_authenticate(user=self.recipient) + shared=self.client.get('/api/drive/items/',{'view':'shared'}) + self.assertEqual(shared.status_code,200,shared.data) + self.assertEqual(shared.data['items'][0]['share_root_id'],str(file.pk)) + self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/trash/').status_code,403) + gone=self.client.delete(f'/api/drive/items/{file.pk}/my-share/') + self.assertEqual(gone.status_code,204) + self.assertFalse(ItemGrant.objects.filter(pk=grant.pk).exists()) + self.assertTrue(DriveItem.objects.filter(pk=file.pk).exists()) + self.assertEqual(path.read_bytes(),b'data') + + def test_old_unsafe_folder_grant_does_not_allow_space_administration(self): + folder,_=self.make_item('shared',folder=True) + child,_=self.make_item('x.txt',parent=folder) + ItemGrant.objects.create(item=folder,user=self.recipient,created_by=self.owner, + permissions=['view','download','add','delete','modify','share','manage_access','create_spaces','manage_space']) + self.assertEqual(item_rights(self.recipient,child),{'view','download','modify','add'}) + self.client.force_authenticate(user=self.recipient) + self.assertEqual(self.client.delete(f'/api/drive/items/{child.pk}/my-share/').status_code,204) + self.assertTrue(DriveItem.objects.filter(pk=child.pk).exists()) + + def test_cleanup_after_five_days_with_recent_item_preserved(self): + stale,path_stale=self.make_item('old.bin') + recent,path_recent=self.make_item('recent.bin') + DriveItem.objects.filter(pk=stale.pk).update(trashed_at=timezone.now()-timedelta(days=6)) + DriveItem.objects.filter(pk=recent.pk).update(trashed_at=timezone.now()-timedelta(days=4)) + # TestCase wraps callbacks in a transaction. Flush on_commit manually. + with self.captureOnCommitCallbacks(execute=True): + call_command('cleanup_trash',days=5) + self.assertFalse(DriveItem.objects.filter(pk=stale.pk).exists()) + self.assertFalse(path_stale.exists()) + self.assertTrue(DriveItem.objects.filter(pk=recent.pk).exists()) + self.assertTrue(path_recent.exists()) + + def test_cleanup_dry_run_and_active_upload_delay(self): + folder,path=self.make_item('pending',folder=True) + file,_=self.make_item('part.txt',parent=folder) + DriveItem.objects.filter(pk=folder.pk).update(trashed_at=timezone.now()-timedelta(days=7)) + call_command('cleanup_trash',days=5,dry_run=True) + self.assertTrue(DriveItem.objects.filter(pk=folder.pk).exists()) + session=UploadSession.objects.create(owner=self.owner,space=self.private,parent=folder, + name='upload.txt',total_size=5) + call_command('cleanup_trash',days=5) + self.assertTrue(DriveItem.objects.filter(pk=folder.pk).exists()) + session.delete() + with self.captureOnCommitCallbacks(execute=True): + call_command('cleanup_trash',days=5) + self.assertFalse(DriveItem.objects.filter(pk=folder.pk).exists()) + self.assertFalse(DriveItem.objects.filter(pk=file.pk).exists()) + self.assertFalse(path.exists()) diff --git a/backend/drive/trash_cleanup.py b/backend/drive/trash_cleanup.py new file mode 100644 index 0000000..ef2c33f --- /dev/null +++ b/backend/drive/trash_cleanup.py @@ -0,0 +1,46 @@ +"""Permanent deletion shared by the trash API and scheduled retention cleanup.""" +from django.db import transaction +from .models import DriveItem, UploadSession +from .services import descendant_files +from .storage_layout import disk_path, file_location + + +def has_active_uploads_in_tree(item): + """Do not purge directories while a resumable upload targets a descendant.""" + if not item.is_folder: + return False + pending = [item.pk] + while pending: + batch, pending = pending[:250], pending[250:] + if UploadSession.objects.filter(parent_id__in=batch).exists(): + return True + pending.extend(DriveItem.objects.filter(parent_id__in=batch, is_folder=True).values_list('pk', flat=True)) + return False + + +def delete_trashed_item(item): + """Delete the SQL tree, then its tracked files after commit. + + Untracked files and directories are not erased. Failures resolving a stored + path abort before the SQL delete; missing physical files are tolerated. + """ + if item.trashed_at is None: + raise ValueError('L’élément doit être dans la corbeille.') + if has_active_uploads_in_tree(item): + raise ValueError('Un transfert est encore en cours dans ce dossier.') + files = [disk_path(key) for key in descendant_files(item)] + folder = disk_path(file_location(item.space, item.parent, item.name)) if item.is_folder else None + with transaction.atomic(): + item.delete() + def remove_files(): + for path in files: + path.unlink(missing_ok=True) + if folder and folder.is_dir() and not folder.is_symlink(): + # Only remove empty directories, never user data untracked by SQL. + for candidate in sorted(folder.rglob('*'), key=lambda p:len(p.parts), reverse=True): + if candidate.is_dir() and not candidate.is_symlink(): + try: candidate.rmdir() + except OSError: pass + try: folder.rmdir() + except OSError: pass + transaction.on_commit(remove_files) diff --git a/backend/drive/urls.py b/backend/drive/urls.py index fc51ab0..b8ff2d9 100644 --- a/backend/drive/urls.py +++ b/backend/drive/urls.py @@ -9,6 +9,7 @@ urlpatterns=[ path('admin/users//access///',views_spaces.admin_revoke_user_access), path('shares/users/search/',views_shares.search_share_users), path('items//shares/',views_shares.manage_item_shares), + path('items//my-share/',views_shares.leave_item_share), path('items//shares//',views_shares.remove_item_share), path('items//shares//permissions/',views_shares.update_item_share), path('items//public-links/',views_shares.public_links), diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py index df3016c..4e3a422 100644 --- a/backend/drive/views_items.py +++ b/backend/drive/views_items.py @@ -1,6 +1,4 @@ """Space-bound navigation, folder management and trash endpoints.""" -from pathlib import Path -from django.conf import settings from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Sum, Count @@ -11,8 +9,9 @@ from .models import DriveItem, DriveSpace, ItemGrant from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space from .permissions import has_space_right, item_rights from .quotas import quota_state -from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination, descendant_files -from .storage_layout import disk_path, file_location, assert_available, prepare_parent, relocate_item, NameConflict +from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination +from .storage_layout import assert_available, prepare_parent, relocate_item, NameConflict +from .trash_cleanup import delete_trashed_item @api_view(['GET']) @@ -71,6 +70,11 @@ def listing(request): if readable(n,user,allow_trashed=view=='trash'): info=item_info(n,user) if view in ('trash','shared'):info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers' + if view=='shared': + for ancestor in chain(n): + if ItemGrant.objects.filter(item=ancestor,user=user).exists(): + info['share_root_id']=str(ancestor.pk) + break items.append(info) if len(items)>=500:break trail=[] @@ -138,6 +142,15 @@ def update_item(request,item_id): except PermissionError as exc:return error(str(exc),403) if 'is_shared' in data: return error('Le partage global est désactivé : utilise les espaces et invitations.') + if 'parent' in data and (new_parent.pk if new_parent else None)!=item.parent_id: + # Moving to the space root requires space membership with Add. + # A recipient can reorganize only inside a folder that grants Add. + if new_parent is None and not has_space_right(request.user,item.space,'add'): + return error('Déplacement hors du dossier partagé interdit.',403) + if not has_space_right(request.user,item.space,'modify'): + # Do not allow moving the shared root itself: it belongs to its creator. + if ItemGrant.objects.filter(item=item,user=request.user).exists(): + return error('Impossible de déplacer le dossier ou fichier partagé lui-même.',403) if new_parent and (new_parent.pk==item.pk or item.pk in [n.pk for n in chain(new_parent)]): return error('Déplacement impossible dans un sous-dossier de cet élément.') if new_name==item.name and (new_parent.pk if new_parent else None)==item.parent_id: @@ -179,24 +192,8 @@ def delete_item(request,item_id): item=get_item(item_id) if not writable(item,request.user,'delete'):return error('Accès refusé.',403) if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.') - files=descendant_files(item) - folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None - with transaction.atomic(): - item.delete() - def remove_files(): - root=Path(settings.DRIVE_STORAGE_ROOT).resolve() - for key in files: - path=(root/key).resolve() - if path.is_relative_to(root):path.unlink(missing_ok=True) - if folder_rel: - # Remove only empty folders. Untracked data is never deleted. - base=disk_path(folder_rel) - if base.is_dir(): - for candidate in sorted(base.rglob('*'),key=lambda p:len(p.parts),reverse=True): - if candidate.is_dir() and not candidate.is_symlink(): - try:candidate.rmdir() - except OSError:pass - try:base.rmdir() - except OSError:pass - transaction.on_commit(remove_files) + try: + delete_trashed_item(item) + except (OSError,ValueError) as exc: + return error(f'Suppression impossible : {exc}',409) return Response(status=204) diff --git a/backend/drive/views_shares.py b/backend/drive/views_shares.py index f743c18..68089e8 100644 --- a/backend/drive/views_shares.py +++ b/backend/drive/views_shares.py @@ -1,4 +1,4 @@ -"""Read-only sharing of individual items and folder descendants. +"""Scoped user sharing of individual items and folder descendants. User shares grant no space-level privileges; public links use cryptographically random bearer tokens and keep only SHA-256 digests in the database. @@ -17,7 +17,7 @@ from rest_framework.response import Response from .models import DriveItem, ItemGrant, PublicItemLink from .services import error, get_item, readable, writable, chain, item_info from .spaces import storage_path -from .permissions import item_rights, assert_delegation, PRESETS, normalized +from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS from .views_content import safe_preview_type, read_bytes @@ -27,7 +27,7 @@ def shareable(item, user): def share_info(grant): u=grant.user - return {'id': grant.pk,'permissions':grant.permissions if grant.permissions is not None else list(PRESETS['reader']),'user_id':u.pk,'username':u.username, + return {'id': grant.pk,'permissions':sorted((set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader'])) & set(available_item_share_rights(grant.item)),key=('view','download','modify','add').index),'user_id':u.pk,'username':u.username, 'first_name':u.first_name,'last_name':u.last_name} @@ -50,7 +50,7 @@ def manage_item_shares(request,item_id): item=get_item(item_id) if not shareable(item,request.user):return error('Partage non autorisé.',403) if request.method=='GET': - grants=ItemGrant.objects.filter(item=item).select_related('user').order_by('user__username') + grants=ItemGrant.objects.filter(item=item).select_related('user','item').order_by('user__username') links=PublicItemLink.objects.filter(item=item,revoked_at__isnull=True).order_by('-created_at') return Response({'users':[share_info(g) for g in grants], 'links':[{'id':l.pk,'created_at':l.created_at.isoformat()} for l in links]}) @@ -61,7 +61,7 @@ def manage_item_shares(request,item_id): except get_user_model().DoesNotExist:return error('Utilisateur introuvable.',404) if account.pk==request.user.pk:return error('Cet élément t’appartient déjà.') try: - rights=assert_delegation(item_rights(request.user,item),request.data.get('permissions',list(PRESETS['reader']))) + rights=assert_item_delegation(item_rights(request.user,item),request.data.get('permissions',list(PRESETS['reader'])),item) except ValueError as exc:return error(str(exc),403) grant,created=ItemGrant.objects.get_or_create(item=item,user=account,defaults={'created_by':request.user,'permissions':rights}) if not created:return error('Cet élément est déjà partagé avec cet utilisateur.',409) @@ -73,11 +73,11 @@ def update_item_share(request,item_id,grant_id): item=get_item(item_id) if not shareable(item,request.user):return error('Partage non autorisé.',403) try: - rights=assert_delegation(item_rights(request.user,item), request.data.get('permissions')) + rights=assert_item_delegation(item_rights(request.user,item), request.data.get('permissions'),item) except ValueError as exc:return error(str(exc),403) changed=ItemGrant.objects.filter(item=item,pk=grant_id).update(permissions=rights) if not changed:return error('Partage introuvable.',404) - return Response(share_info(ItemGrant.objects.select_related('user').get(pk=grant_id))) + return Response(share_info(ItemGrant.objects.select_related('user','item').get(pk=grant_id))) @api_view(['DELETE']) @@ -89,6 +89,18 @@ def remove_item_share(request,item_id,grant_id): return Response(status=204) +@api_view(['DELETE']) +def leave_item_share(request,item_id): + """Remove only the current recipient's share, never the underlying item.""" + item=get_item(item_id) + for node in chain(item): + grant=ItemGrant.objects.filter(item=node,user=request.user).first() + if grant is not None: + grant.delete() + return Response(status=204) + return error('Aucun partage à retirer.',404) + + @api_view(['POST']) def public_links(request,item_id): item=get_item(item_id) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 1212435..4686539 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -12,4 +12,11 @@ until python manage.py migrate --noinput; do sleep 2 done python manage.py ensure_admin +# Nettoyage automatique au lancement puis chaque heure. +( + while :; do + python manage.py cleanup_trash --days 5 || echo 'Nettoyage de la corbeille reporté.' >&2 + sleep 3600 + done +) & exec python manage.py runserver 0.0.0.0:1029 diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 21648f1..d22d72c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -69,6 +69,7 @@ export const drive={ shareItem:(id,user_id,permissions)=>api.post(`/drive/items/${id}/shares/`,{user_id,permissions}).then(r=>r.data), shareItemPermissions:(id,grant_id,permissions)=>api.patch(`/drive/items/${id}/shares/${grant_id}/permissions/`,{permissions}).then(r=>r.data), unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`), + leaveItemShare:id=>api.delete(`/drive/items/${id}/my-share/`), publicItem:id=>api.post(`/drive/items/${id}/public-links/`).then(r=>r.data), revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`), publicBrowse:(token,parent)=>publicApi.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data), diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx index 3032945..a73cd89 100644 --- a/frontend/src/components/drive/ContextActions.jsx +++ b/frontend/src/components/drive/ContextActions.jsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users, Share2 } from 'lucide-react' -export const fileOptions = (item, trash) => { +export const fileOptions = (item, trash, shared=false) => { if (!item) return [] const rights = new Set(item.permissions || []) if (trash) return rights.has('delete') ? [ @@ -17,7 +17,7 @@ export const fileOptions = (item, trash) => { { id: 'rename', title: 'Renommer', icon: Pencil }, { id: 'move', title: 'Déplacer', icon: FolderInput }, ] : []), - ...(rights.has('delete') ? [{id:'trash',title:'Mettre à la corbeille',icon:Trash2,danger:true}] : []), + ...(shared && item.share_root_id ? [{id:'remove-share',title:'Retirer de mes partages',icon:Trash2,danger:true}] : rights.has('delete') && !shared ? [{id:'trash',title:'Mettre à la corbeille',icon:Trash2,danger:true}] : []), ] } diff --git a/frontend/src/components/drive/DriveExplorer.jsx b/frontend/src/components/drive/DriveExplorer.jsx index 4310550..23984bc 100644 --- a/frontend/src/components/drive/DriveExplorer.jsx +++ b/frontend/src/components/drive/DriveExplorer.jsx @@ -21,7 +21,7 @@ export default function DriveExplorer({ controller, user }) { const [context, setContext] = useState(null) const closeContext = useCallback(() => setContext(null), []) const openContext = (item,x,y) => { - const options=fileOptions(item,isTrash) + const options=fileOptions(item,isTrash,view==='shared') if (options.length) setContext({item,x,y,options}) } useEffect(() => { @@ -115,7 +115,7 @@ export default function DriveExplorer({ controller, user }) { )} - performItemAction(context.item,type)} />