This commit is contained in:
Louis Mazin 2026-10-14 17:27:37 +02:00
parent d1872e7c7d
commit 90004d4540
25 changed files with 574 additions and 289 deletions

View File

@ -0,0 +1,10 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('drive', '0016_archived_drive_account')]
operations = [
migrations.AddField(model_name='drivespace', name='parent_permissions', field=models.JSONField(null=True, blank=True, default=None)),
migrations.AddField(model_name='spacegrant', name='permissions', field=models.JSONField(null=True, blank=True, default=None)),
migrations.AddField(model_name='spaceinvitation', name='permissions', field=models.JSONField(null=True, blank=True, default=None)),
migrations.AddField(model_name='itemgrant', name='permissions', field=models.JSONField(null=True, blank=True, default=None)),
]

View File

@ -19,6 +19,7 @@ class DriveSpace(models.Model):
display_name = models.CharField(max_length=120)
can_view_parent = models.BooleanField(default=True)
can_write_parent = models.BooleanField(default=False)
parent_permissions = models.JSONField(null=True, blank=True, default=None)
can_invite = models.BooleanField(default=False)
quota_bytes = models.BigIntegerField(null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
@ -33,6 +34,7 @@ class SpaceGrant(models.Model):
space = models.ForeignKey(DriveSpace, on_delete=models.CASCADE, related_name='access_grants')
can_write = models.BooleanField(default=False)
can_invite = models.BooleanField(default=False)
permissions = models.JSONField(null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@ -48,6 +50,7 @@ class SpaceInvitation(models.Model):
can_write_parent = models.BooleanField(default=False)
can_invite = models.BooleanField(default=False)
quota_bytes = models.BigIntegerField(null=True, blank=True, default=None)
permissions = models.JSONField(null=True, blank=True, default=None)
expires_at = models.DateTimeField()
used_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='redeemed_invitations')
used_at = models.DateTimeField(null=True, blank=True)
@ -109,6 +112,7 @@ class ItemGrant(models.Model):
item = models.ForeignKey(DriveItem, on_delete=models.CASCADE, related_name='user_shares')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='shared_drive_items')
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+')
permissions = models.JSONField(null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:

View File

@ -0,0 +1,84 @@
"""Granular, non-escalating rights for spaces and individual shares."""
from .models import ItemGrant, SpaceGrant
RIGHTS = ('view', 'download', 'add', 'modify', 'delete', 'share', 'manage_access', 'create_spaces', 'manage_space')
PRESETS = {
'reader': ('view', 'download'),
'contributor': ('view', 'download', 'add', 'modify'),
'manager': RIGHTS,
}
def legacy_rights(read=True, write=False, invite=False):
result = ['view', 'download'] if read else []
if write:
result += ['add', 'modify', 'delete', 'share']
if invite:
result += ['manage_access', 'create_spaces']
return result
def normalized(value):
if not isinstance(value, list) or len(value) > len(RIGHTS) or any(not isinstance(k, str) or k not in RIGHTS for k in value):
raise ValueError('Liste des permissions invalide.')
rights = set(value)
if 'view' not in rights:
raise ValueError('La permission Consulter est obligatoire.')
return sorted(rights, key=RIGHTS.index)
def _is_admin(user):
return user.is_authenticated and user.is_active and (user.is_staff or user.is_superuser)
def space_rights(user, space):
if not user.is_authenticated or not user.is_active or space is None:
return set()
if space.private_owner_id:
# Never expose someone else's private contents, including to admins.
return set(RIGHTS) if space.private_owner_id == user.pk else set()
if _is_admin(user) or space.managed_by_id == user.pk:
return set(RIGHTS)
if space.user_id == user.pk:
return set(RIGHTS)
grant = SpaceGrant.objects.filter(space=space, user=user).first()
if grant:
return set(grant.permissions) if grant.permissions is not None else set(legacy_rights(write=grant.can_write, invite=grant.can_invite))
if space.parent_id and space.can_view_parent and space.user_id == user.pk:
return set(legacy_rights(write=space.can_write_parent, invite=space.can_invite))
# Historical assigned-space access (parent) for legacy sub-users.
owned = getattr(user, 'drive_space', None)
if owned and owned.parent_id == space.pk and owned.can_view_parent:
return set(owned.parent_permissions) if owned.parent_permissions is not None else set(legacy_rights(write=owned.can_write_parent, invite=owned.can_invite))
return set()
def has_space_right(user, space, right):
return right in space_rights(user, space)
def item_rights(user, item):
rights = space_rights(user, item.space)
if rights:
return rights
if not user.is_authenticated or not user.is_active:
return set()
# Share on a parent folder propagates to its descendants.
seen = set()
current = item
while current is not None:
if current.pk in seen:
return set()
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'])
current = current.parent
return set()
def assert_delegation(issuer_rights, desired):
clean = set(normalized(desired))
if not clean.issubset(set(issuer_rights)):
raise ValueError('Tu ne peux pas attribuer des permissions supérieures aux tiennes.')
return normalized(desired)

View File

@ -105,107 +105,61 @@ def trash_occupancy(space):
return int(bytes_in_trash)
def _budget_tree(space):
"""One DB snapshot of all descendant spaces and their direct physical usage.
Shared descendants retain the existing reservation rules for assigned
quotas. A private space's 50-Go cap is NEVER preallocated: its parent counts
only its real files and pending uploads (including its own descendants).
"""
ids=list(subtree_space_ids(space))
nodes={node.pk:node for node in DriveSpace.objects.filter(pk__in=ids)}
children={pk:[] for pk in nodes}
for node in nodes.values():
if node.parent_id in children:children[node.parent_id].append(node.pk)
direct={pk:0 for pk in nodes}
for pk,amount in DriveItem.objects.filter(space_id__in=ids,is_folder=False).values('space_id').annotate(total=Sum('size')).values_list('space_id','total'):
direct[pk]+=int(amount or 0)
for pk,amount in UploadSession.objects.filter(space_id__in=ids).values('space_id').annotate(total=Sum('total_size')).values_list('space_id','total'):
direct[pk]+=int(amount or 0)
memo={}
def committed(pk):
if pk not in memo:
memo[pk] = direct[pk] + sum(
committed(child) if nodes[child].private_owner_id
else max(nodes[child].quota_bytes or 0, committed(child))
for child in children[pk]
)
return memo[pk]
committed(space.pk)
return memo
def _committed(space):
"""Allocated bytes inside an entire subtree, including nested quota reservations."""
return _budget_tree(space)[space.pk]
def available_for_growth(space):
"""Bytes that can be added directly to space without breaking any ancestor allocation.
Quotas of normal assigned children can absorb growth already reserved in
their ancestors. For private children, the 50-Go cap is NOT reserved: every
newly uploaded byte immediately reduces the parent's available budget.
"""
maximum=MAX_QUOTA_BYTES
absorbed=0
chain=ancestry(space)
committed_tree=_budget_tree(chain[-1])
for ancestor in chain:
committed=committed_tree[ancestor.pk]
if ancestor.quota_bytes is not None:
maximum=min(maximum,ancestor.quota_bytes-committed+absorbed)
# Private caps constrain uploads, but never absorb parent capacity.
if not ancestor.private_owner_id:
absorbed+=max(0,ancestor.quota_bytes-committed)
return None if maximum==MAX_QUOTA_BYTES else max(0,int(maximum))
"""Unreserved quotas: every upload consumes capacity in its space and ALL ancestors."""
limits = []
for node in ancestry(space):
if node.quota_bytes is not None:
used, pending = occupancy(node)
limits.append(node.quota_bytes - used - pending)
return max(0, min(limits)) if limits else None
def assert_new_child_quota(parent, quota):
"""A new nested space cannot reserve more than its parent's unallocated capacity."""
if quota is None:return
remaining=available_for_growth(parent)
if remaining is not None and quota>remaining:
raise ValueError(f'Le quota dépasse les {remaining} octets disponibles dans l’espace parent.')
"""A child limit may not exceed its parent's maximum, but reserves nothing."""
if quota is None and parent.quota_bytes is not None:
raise ValueError('Le quota du sous-espace doit respecter la limite de son parent.')
if quota is not None:
limits = [node.quota_bytes for node in ancestry(parent) if node.quota_bytes is not None]
if limits and quota > min(limits):
raise ValueError('Le quota du sous-espace dépasse la limite de son parent.')
def assert_updated_quota(space, quota):
"""Validate nested allocations, including sibling limits and actual disk use.
The old reservation is replaced atomically with the requested value. Parent
quotas must be large enough for their direct content and all child quotas.
"""
chain=ancestry(space)
committed_tree=_budget_tree(chain[-1])
committed=committed_tree[space.pk]
if quota is not None and quota<committed:
raise ValueError('Le quota est inférieur aux fichiers et sous-espaces déjà attribués.')
current=space.quota_bytes
if current==quota:return
# Compute the changed reservation at every ancestor: each parent's
# committed usage has this subtree's reserved/actual allocation substituted.
old_child=max(current or 0,committed)
new_child=max(quota or 0,committed)
for ancestor in chain[1:]:
amount=committed_tree[ancestor.pk]-old_child+new_child
if ancestor.quota_bytes is not None and amount>ancestor.quota_bytes:
raise ValueError(f'Quota impossible : capacité de « {ancestor.display_name} » dépassée.')
old_child=max(ancestor.quota_bytes or 0,committed_tree[ancestor.pk])
new_child=max(ancestor.quota_bytes or 0,amount)
"""Validate actual occupancy, nested maximums, never preallocate capacity."""
used, pending = occupancy(space)
if quota is not None and quota < used + pending:
raise ValueError('Le quota ne peut pas être inférieur au stockage utilisé.')
if space.parent_id:
assert_new_child_quota(space.parent, quota)
if quota is not None:
# Keep the hierarchy valid if the parent limit shrinks.
descendants = subtree_space_ids(space) - {space.pk}
existing = DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=False)
if existing.filter(quota_bytes__gt=quota).exists():
raise ValueError('Le quota est inférieur à la limite d’un sous-espace existant.')
if quota == 0 and DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=True).exists():
raise ValueError('Un sous-espace sans limite empêche ce changement.')
def quota_state(space):
used, reserved = occupancy(space)
trashed = trash_occupancy(space)
available = available_for_growth(space)
# This number indicates effective capacity already unavailable within a
# child cap, including bytes used elsewhere in a shared parent.
effective_used = used
if not space.private_owner_id and space.quota_bytes is not None and available is not None:
effective_used = max(used + reserved, space.quota_bytes - available)
return {
'quota_bytes': space.quota_bytes,
'used_bytes':used,
'used_bytes': max(0, effective_used - reserved),
'actual_used_bytes': used,
'active_bytes': max(0, used - trashed),
'trashed_bytes': trashed,
'reserved_bytes': reserved,
'available_bytes': available,
'effective_limit_bytes':None if available is None else used+reserved+available,
'effective_limit_bytes': None if available is None else effective_used + available,
}

View File

@ -5,6 +5,7 @@ from django.http import Http404
from rest_framework.response import Response
from .models import DriveItem, ItemGrant
from .spaces import can_read_space, can_write_space
from .permissions import has_space_right, item_rights
def error(message, code=400):
@ -22,6 +23,7 @@ def item_info(item, user=None):
}
if user is not None:
data['can_write'] = writable(item,user)
data['permissions'] = sorted(item_rights(user, item))
return data
@ -45,18 +47,13 @@ def chain(item):
def readable(item, user, allow_trashed=False):
space_allowed = can_read_space(user,item.space)
if not space_allowed and getattr(user,'is_authenticated',False) and user.is_active:
space_allowed = ItemGrant.objects.filter(user=user,item_id__in=[node.pk for node in chain(item)]).exists()
if not space_allowed:
if 'view' not in item_rights(user, item):
return False
if not allow_trashed and any(n.trashed_at is not None for n in chain(item)):
return False
return True
return allow_trashed or not any(n.trashed_at is not None for n in chain(item))
def writable(item,user):
return can_write_space(user,item.space)
def writable(item, user, action='modify'):
return action in item_rights(user, item)
def valid_name(name):
@ -77,7 +74,7 @@ def target_parent(parent_id, user, space):
raise ValueError('Dossier de destination introuvable.')
if parent.space_id != space.id:
raise PermissionError('Impossible de déplacer un élément entre deux espaces.')
if not parent.is_folder or not readable(parent,user) or not writable(parent,user):
if not parent.is_folder or not readable(parent,user) or not writable(parent,user,'add'):
raise PermissionError('Dossier de destination inaccessible en écriture.')
return parent
@ -95,3 +92,20 @@ def descendant_files(node):
key=node.storage_key
files.append(node.storage_location or f'{key[:2]}/{key}')
return files
def writable_destination(user, space_id, parent_id):
"""Uploads may target a folder shared with this user, without space membership."""
from .spaces import resolve_space
from .permissions import has_space_right
if parent_id:
folder=get_item(parent_id)
if space_id is not None and str(folder.space_id)!=str(space_id):
raise PermissionError('Le dossier ne correspond pas à cet espace.')
if not folder.is_folder or not readable(folder,user) or not writable(folder,user,'add'):
raise PermissionError('Dossier inaccessible en ajout.')
return folder.space, folder
space=resolve_space(user,space_id)
if not has_space_right(user,space,'add'):
raise PermissionError('Ajout interdit dans cet espace.')
return space,None

View File

@ -7,6 +7,7 @@ from django.db import transaction
from django.http import Http404
from .models import DriveSpace, SpaceGrant, DriveItem
from .quotas import PRIVATE_SPACE_QUOTA_BYTES
from .permissions import has_space_right, space_rights
def is_admin(user):
@ -120,53 +121,16 @@ 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.
if space.private_owner_id:
return space.private_owner_id == user.pk
if is_admin(user) or space.managed_by_id == user.pk:
return True
own = ensure_space(user)
if own and own.pk == space.pk and own.user_id == user.pk:
return True
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
return has_space_right(user, space, 'view')
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.
if space.private_owner_id:
return space.private_owner_id == user.pk
if is_admin(user) or space.managed_by_id == user.pk:
return True
own = ensure_space(user)
if own and own.pk == space.pk and own.user_id == user.pk:
return True
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)
# Kept for legacy callers: use explicit right checks on each operation.
return has_space_right(user, space, 'add') or has_space_right(user, space, 'modify')
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:
return True
if space.user_id == user.pk and space.can_invite:
return True
grant = _grant(user, space)
return bool(grant and grant.can_invite)
return has_space_right(user, space, 'manage_access')
def resolve_space(user, space_id=None, write=False):
@ -185,7 +149,7 @@ def resolve_space(user, space_id=None, write=False):
def space_info(space, user):
from .quotas import quota_state
owner = space.private_owner or space.user or space.managed_by
owner = space.private_owner or space.user or space.managed_by or user
return {
'id': space.pk, 'user_id': owner.pk, 'username': owner.username,
'first_name': owner.first_name, 'last_name': owner.last_name,
@ -193,11 +157,13 @@ 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': 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)),
'permissions': sorted(space_rights(user, space)),
'can_edit': not space.private_owner_id and has_space_right(user, space, 'manage_space'),
'can_delete': not space.private_owner_id and has_space_right(user, space, 'manage_space') and (space.parent_id is not None or is_admin(user)),
'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,
'parent_permissions': space.parent_permissions if space.parent_permissions is not None else (['view','download'] + (['add','modify','delete','share'] if space.can_write_parent else []) + (['manage_access','create_spaces'] if space.can_invite else [])),
'created_at': space.created_at.isoformat(),
}

View File

@ -0,0 +1,112 @@
"""Run: USE_SQLITE=true python manage.py test drive.test_granular_permissions"""
import tempfile
import uuid
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.core.files.uploadedfile import SimpleUploadedFile
from rest_framework.test import APIClient
from .models import DriveItem, DriveSpace, SpaceGrant, ItemGrant
from .spaces import ensure_space, ensure_private_space
from .quotas import quota_state, occupancy
class GranularPermissionTests(TestCase):
def setUp(self):
tmp=tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.dir=Path(tmp.name)
settings=override_settings(DRIVE_STORAGE_ROOT=self.dir, FILE_UPLOAD_TEMP_DIR=str(self.dir/'.uploads'))
settings.enable();self.addCleanup(settings.disable)
User=get_user_model()
self.admin=User.objects.create_superuser('test-admin','admin@localhost','ComplexPassword123!')
self.member=User.objects.create_user('test-member',password='ComplexPassword123!')
self.guest=User.objects.create_user('test-guest',password='ComplexPassword123!')
self.root=ensure_space(self.admin)
self.private=ensure_private_space(self.member)
self.client=APIClient()
self.client.force_authenticate(self.admin)
def test_delegation_and_independent_rights(self):
rights=['view','download','manage_access']
SpaceGrant.objects.create(user=self.member,space=self.root,permissions=rights,can_invite=True)
self.client.force_authenticate(self.member)
self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'NO'},format='json').status_code,403)
created=self.client.post('/api/drive/invitations/',{
'parent_space':self.root.pk,'permissions':['view','download']},format='json')
self.assertEqual(created.status_code,201,created.data)
escalation=self.client.post('/api/drive/invitations/',{
'parent_space':self.root.pk,'permissions':['view','modify']},format='json')
self.assertEqual(escalation.status_code,403)
no_create=self.client.post('/api/drive/spaces/create/',{
'parent_space':self.root.pk,'display_name':'Impossible','quota_bytes':None},format='json')
self.assertEqual(no_create.status_code,403)
self.client.force_authenticate(self.admin)
grant=self.client.post('/api/drive/spaces/users/',{
'parent_space':self.root.pk,'user_id':self.guest.pk,
'permissions':['view','add']},format='json',QUERY_STRING=f'parent_space={self.root.pk}')
self.assertEqual(grant.status_code,201,grant.data)
self.client.force_authenticate(self.guest)
self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'OK'},format='json').status_code,201)
self.assertEqual(self.client.post('/api/drive/invitations/',{'parent_space':self.root.pk},format='json').status_code,403)
def test_granular_folder_share_and_anonymous_preview(self):
folder=DriveItem.objects.create(owner=self.member,space=self.private,name='Photos',is_folder=True)
image=DriveItem.objects.create(owner=self.member,space=self.private,parent=folder,name='photo.png',
storage_key=str(uuid.uuid4()),storage_location=f'{self.private.relative_path}/Photos/photo.png',size=8,mime_type='image/png')
filename=self.dir/image.storage_location
filename.parent.mkdir(parents=True,exist_ok=True)
filename.write_bytes(b'PNGDATA!')
self.client.force_authenticate(self.member)
share=self.client.post(f'/api/drive/items/{folder.pk}/shares/',{
'user_id':self.guest.pk,'permissions':['view','download','add']},format='json')
self.assertEqual(share.status_code,201,share.data)
self.client.force_authenticate(self.guest)
self.assertEqual(self.client.post('/api/drive/folders/',{
'name':'Nouveau','parent':str(folder.pk),'space':self.private.pk},format='json').status_code,201)
self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/trash/').status_code,403)
self.client.force_authenticate(self.member)
result=self.client.post(f'/api/drive/items/{image.pk}/public-links/',{},format='json')
self.assertEqual(result.status_code,201,result.data)
anonymous=APIClient()
token=result.data['token']
inline=anonymous.get(f'/api/public/{token}/files/{image.pk}/?inline=1')
self.assertEqual(inline.status_code,200)
self.assertEqual(inline['Content-Type'],'image/png')
self.assertEqual(b''.join(inline.streaming_content),b'PNGDATA!')
partial=anonymous.get(f'/api/public/{token}/files/{image.pk}/?inline=1',HTTP_RANGE='bytes=0-2')
self.assertEqual(partial.status_code,206)
self.assertEqual(b''.join(partial.streaming_content),b'PNG')
self.client.force_authenticate(self.member)
self.assertEqual(self.client.patch(f'/api/drive/items/{folder.pk}/shares/{share.data["id"]}/permissions/',{
'permissions':['view']},format='json').status_code,200)
self.client.force_authenticate(self.guest)
self.assertEqual(self.client.post('/api/drive/folders/',{
'name':'Interdit','parent':str(folder.pk),'space':self.private.pk},format='json').status_code,403)
self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/link/',{'inline':False},format='json').status_code,403)
self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/link/',{'inline':True},format='json').status_code,200)
def test_unreserved_child_quota_parent_usage_reduces_child_capacity(self):
gib=1024**3
self.root.quota_bytes=50*gib
self.root.save(update_fields=['quota_bytes'])
self.client.force_authenticate(self.admin)
res=self.client.post('/api/drive/spaces/create/',{
'parent_space':self.root.pk,'display_name':'Sous espace','quota_bytes':30*gib},format='json')
self.assertEqual(res.status_code,201,res.data)
child=DriveSpace.objects.get(pk=res.data['id'])
self.assertEqual(child.parent_id,self.root.pk)
DriveItem.objects.create(space=self.root,owner=self.admin,name='parent.bin',size=20*gib,
storage_key=str(uuid.uuid4()),storage_location='unused')
self.assertEqual(quota_state(self.root)['used_bytes'],20*gib)
self.assertEqual(quota_state(child)['used_bytes'],0)
DriveItem.objects.filter(space=self.root).update(size=30*gib)
self.assertEqual(quota_state(self.root)['used_bytes'],30*gib)
self.assertEqual(quota_state(child)['used_bytes'],10*gib)
self.assertEqual(quota_state(child)['available_bytes'],20*gib)
DriveItem.objects.create(space=child,owner=self.admin,name='child.bin',size=10*gib,
storage_key=str(uuid.uuid4()),storage_location='unused2')
self.assertGreaterEqual(quota_state(self.root)['used_bytes'],10*gib)
self.assertEqual(quota_state(self.root)['used_bytes'],40*gib)
self.assertEqual(quota_state(child)['used_bytes'],20*gib)
self.assertEqual(occupancy(child)[0],10*gib)

View File

@ -10,6 +10,7 @@ urlpatterns=[
path('shares/users/search/',views_shares.search_share_users),
path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares),
path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share),
path('items/<uuid:item_id>/shares/<int:grant_id>/permissions/',views_shares.update_item_share),
path('items/<uuid:item_id>/public-links/',views_shares.public_links),
path('items/<uuid:item_id>/public-links/<int:link_id>/',views_shares.revoke_public_link),
path('spaces/create/',views_spaces.create_shared_space),

View File

@ -10,6 +10,7 @@ from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from .services import error, get_item, readable
from .spaces import storage_path
from .permissions import item_rights
IMAGE_TYPES = {'image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp'}
VIDEO_TYPES = {'video/mp4','video/webm','video/ogg','video/quicktime','video/x-m4v'}
@ -31,6 +32,8 @@ def safe_preview_type(item):
def content_url(request,item_id):
item=get_item(item_id)
if item.is_folder or not readable(item,request.user):return error('Fichier inaccessible.',403)
if not request.data.get('inline',False) and 'download' not in item_rights(request.user,item):
return error('Téléchargement non autorisé.',403)
inline=bool(request.data.get('inline',False))
if inline and not safe_preview_type(item):return error('Aperçu non disponible pour ce type de fichier.',415)
# Encode the authorized identity so access is checked again on every request.
@ -62,7 +65,7 @@ def content(request,item_id):
except (BadSignature,SignatureExpired,ValueError,TypeError,get_user_model().DoesNotExist):
raise Http404
item=get_item(item_id)
if item.is_folder or not readable(item,user):raise Http404
if item.is_folder or not readable(item,user) or (inline=='0' and 'download' not in item_rights(user,item)):raise Http404
media=safe_preview_type(item)
if inline=='1' and not media:raise Http404
path=storage_path(item)

View File

@ -9,8 +9,9 @@ from rest_framework.decorators import api_view
from rest_framework.response import Response
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, descendant_files
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
@ -83,7 +84,8 @@ def listing(request):
for n in ancestors:
trail.append({'id':str(n.id),'name':n.name})
return Response({'items':items,'breadcrumb':trail,'limited':len(items)>=500,
'can_write':can_write_space(user,space) if space else False,'space_id':space.pk if space else None})
'can_write':('add' in item_rights(user,parent)) if view=='shared' and parent else (has_space_right(user,space,'add') if space else False),
'space_id':parent.space_id if view=='shared' and parent else (space.pk if space else None)})
@api_view(['GET'])
@ -100,9 +102,8 @@ def stats(request):
@api_view(['POST'])
def create_folder(request):
try:
space=resolve_space(request.user,request.data.get('space'),write=True)
name=valid_name(request.data.get('name'))
parent=target_parent(request.data.get('parent'),request.user,space)
space,parent=writable_destination(request.user,request.data.get('space'),request.data.get('parent'))
except ValueError as ex:return error(str(ex))
except PermissionError as ex:return error(str(ex),403)
with transaction.atomic():
@ -126,7 +127,7 @@ def create_folder(request):
@api_view(['PATCH'])
def update_item(request,item_id):
item=get_item(item_id)
if not readable(item,request.user) or not writable(item,request.user):
if not readable(item,request.user) or not writable(item,request.user,'modify'):
return error('Modification interdite.',403)
data=request.data
try:
@ -153,7 +154,7 @@ def update_item(request,item_id):
@api_view(['POST'])
def trash_item(request,item_id):
item=get_item(item_id)
if not readable(item,request.user) or not writable(item,request.user):return error('Accès refusé.',403)
if not readable(item,request.user) or not writable(item,request.user,'delete'):return error('Accès refusé.',403)
if item.trashed_at:return error('Élément déjà dans la corbeille.')
item.trashed_at=timezone.now();item.save(update_fields=['trashed_at','updated_at'])
return Response({'ok':True})
@ -162,7 +163,7 @@ def trash_item(request,item_id):
@api_view(['POST'])
def restore_item(request,item_id):
item=get_item(item_id)
if not writable(item,request.user):return error('Accès refusé.',403)
if not writable(item,request.user,'delete'):return error('Accès refusé.',403)
if not item.trashed_at:return error('Cet élément n’est pas dans la corbeille.')
if item.parent and any(n.trashed_at for n in chain(item.parent)):
try:
@ -176,7 +177,7 @@ def restore_item(request,item_id):
@api_view(['DELETE'])
def delete_item(request,item_id):
item=get_item(item_id)
if not writable(item,request.user):return error('Accès refusé.',403)
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

View File

@ -7,7 +7,7 @@ import hashlib
import secrets
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.http import FileResponse, Http404
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse
from django.db.models import Q
from django.utils import timezone
from rest_framework.decorators import api_view, permission_classes, throttle_classes
@ -17,15 +17,17 @@ 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 .views_content import safe_preview_type, read_bytes
def shareable(item, user):
return readable(item, user) and writable(item, user)
return readable(item, user) and 'share' in item_rights(user,item)
def share_info(grant):
u=grant.user
return {'id': grant.pk,'user_id':u.pk,'username':u.username,
return {'id': grant.pk,'permissions':grant.permissions if grant.permissions is not None else list(PRESETS['reader']),'user_id':u.pk,'username':u.username,
'first_name':u.first_name,'last_name':u.last_name}
@ -58,11 +60,26 @@ def manage_item_shares(request,item_id):
try:account=get_user_model().objects.get(pk=user_id,is_active=True)
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à.')
grant,created=ItemGrant.objects.get_or_create(item=item,user=account,defaults={'created_by':request.user})
try:
rights=assert_delegation(item_rights(request.user,item),request.data.get('permissions',list(PRESETS['reader'])))
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)
return Response(share_info(grant),status=201)
@api_view(['PATCH'])
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'))
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)))
@api_view(['DELETE'])
def remove_item_share(request,item_id,grant_id):
item=get_item(item_id)
@ -75,7 +92,7 @@ def remove_item_share(request,item_id,grant_id):
@api_view(['POST'])
def public_links(request,item_id):
item=get_item(item_id)
if not shareable(item,request.user) or not readable(item,request.user):
if not shareable(item,request.user) or not {'view','download','share'}.issubset(item_rights(request.user,item)):
return error('Partage non autorisé.',403)
token=secrets.token_urlsafe(32)
PublicItemLink.objects.create(item=item,token_hash=hashlib.sha256(token.encode()).hexdigest(),created_by=request.user)
@ -118,10 +135,10 @@ def public_share(request,token):
else:folder=root
if not folder.is_folder or not in_public_tree(root,folder):
if not parent_id and not root.is_folder:
return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':False},'items':[]})
return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':False,'size':root.size,'mime_type':root.mime_type},'items':[]})
raise Http404
qs=DriveItem.objects.filter(parent=folder,trashed_at__isnull=True).order_by('-is_folder','name')[:501]
items=[{'id':str(i.pk),'name':i.name,'is_folder':i.is_folder,'size':i.size} for i in qs]
items=[{'id':str(i.pk),'name':i.name,'is_folder':i.is_folder,'size':i.size,'mime_type':i.mime_type} for i in qs]
# Never list ancestors outside the shared root.
breadcrumb=[]
for node in reversed(chain(folder)):
@ -135,14 +152,49 @@ def public_share(request,token):
@permission_classes([AllowAny])
@throttle_classes([AnonRateThrottle])
def public_file(request,token,item_id):
"""Bearer-link file access; inline previews only for safe MIME types."""
import re
root=get_public_root(token)
item=get_item(item_id)
if item.is_folder or not in_public_tree(root,item):raise Http404
path=storage_path(item)
if not path.is_file():raise Http404
response=FileResponse(path.open('rb'),as_attachment=True,filename=item.name,
content_type='application/octet-stream')
inline=request.query_params.get('inline')=='1'
preview=safe_preview_type(item)
if inline and not preview:return error('Aperçu indisponible pour ce format.',415)
content_type=preview if inline else 'application/octet-stream'
size=path.stat().st_size
byte_range=request.headers.get('Range')
if byte_range:
match=re.fullmatch(r'bytes=(\d*)-(\d*)',byte_range.strip())
if not match or (not match[1] and not match[2]):
response=HttpResponse(status=416)
response['Content-Range']=f'bytes */{size}'
return response
if match[1]:
start=int(match[1]);end=int(match[2]) if match[2] else size-1
else:
start=max(0,size-int(match[2]));end=size-1
end=min(end,size-1)
if start>=size or end<start:
response=HttpResponse(status=416)
response['Content-Range']=f'bytes */{size}'
return response
response=StreamingHttpResponse(read_bytes(path,start,end) if request.method=='GET' else [],status=206,content_type=content_type)
response['Content-Range']=f'bytes {start}-{end}/{size}'
response['Content-Length']=end-start+1
elif request.method=='HEAD':
response=HttpResponse(content_type=content_type)
response['Content-Length']=size
else:
response=FileResponse(path.open('rb'),as_attachment=not inline,filename=item.name,content_type=content_type)
response['Accept-Ranges']='bytes'
response['X-Content-Type-Options']='nosniff'
response['Cache-Control']='no-store'
response['Referrer-Policy']='no-referrer'
if inline:
# HTML/SVG are served as inert text, never as executable documents.
if content_type.startswith('text/plain'):
response['Content-Security-Policy']='sandbox'
response['X-Frame-Options']='SAMEORIGIN'
return response

View File

@ -19,6 +19,7 @@ from .models import (DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadS
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 .permissions import RIGHTS, PRESETS, normalized, legacy_rights, space_rights, has_space_right, assert_delegation
from .quotas import (parse_quota, locked_quota_tree,
assert_new_child_quota, assert_updated_quota)
@ -43,7 +44,7 @@ def enforce_delegation(user, space, *, view=True, write=False, invite=False):
def invitation_info(inv):
return {'id':str(inv.pk), 'created_at':inv.created_at.isoformat(),
return {'permissions': inv.permissions if inv.permissions is not None else legacy_rights(write=inv.can_write_parent, invite=inv.can_invite), 'id':str(inv.pk), 'created_at':inv.created_at.isoformat(),
'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at),
'revoked':bool(inv.revoked_at),
'can_view_parent':inv.can_view_parent,'can_write_parent':inv.can_write_parent,
@ -121,9 +122,17 @@ def update_member(request,space_id):
try:child=DriveSpace.objects.select_related('parent','user').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if child.private_owner_id or not child.parent_id:return error('Impossible de modifier cet espace ici.',403)
if not manager(request.user,child.parent):return error('Modification interdite.',403)
if not has_space_right(request.user,child.parent,'manage_access'):return error('Modification interdite.',403)
changes=[]
for key in ('can_view_parent','can_write_parent','can_invite'):
if 'permissions' in request.data:
try:rights=assert_delegation(space_rights(request.user,child.parent),request.data['permissions'])
except ValueError as exc:return error(str(exc),403)
child.parent_permissions=rights;changes.append('parent_permissions')
child.can_view_parent='view' in rights
child.can_write_parent='add' in rights and 'modify' in rights
child.can_invite='manage_access' in rights
changes+=['can_view_parent','can_write_parent','can_invite']
for key in (() if 'permissions' in request.data else ('can_view_parent','can_write_parent','can_invite')):
if key in request.data:
if not isinstance(request.data[key],bool):return error('Les permissions doivent être des booléens.')
setattr(child,key,request.data[key]);changes.append(key)
@ -153,7 +162,7 @@ def update_space_quota(request, space_id):
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if space.private_owner_id:
return error('Le quota des espaces privés est fixé à 50 Go.',403)
if not (is_admin(request.user) or (space.parent_id and not space.private_owner_id and manager(request.user,space.parent))):
if not has_space_right(request.user,space,'manage_space'):
return error('Modification du quota interdite.',403)
if 'quota_bytes' not in request.data:return error('quota_bytes est obligatoire.')
try:_update_quota(space,request.data['quota_bytes'])
@ -173,7 +182,12 @@ def create_invitation(request):
if 'can_view_parent' not in request.data:fields['can_view_parent']=True
if not fields['can_view_parent']:
return error('L’invitation donne nécessairement accès en lecture à cet espace.')
try:enforce_delegation(request.user,parent,view=fields['can_view_parent'],write=fields['can_write_parent'],invite=fields['can_invite'])
try:
rights = assert_delegation(space_rights(request.user,parent),
request.data['permissions'] if 'permissions' in request.data else legacy_rights(
write=fields['can_write_parent'],invite=fields['can_invite']))
fields['can_write_parent'] = 'add' in rights and 'modify' in rights
fields['can_invite'] = 'manage_access' in rights
except ValueError as exc:return error(str(exc),403)
try:days=int(request.data.get('expires_days',7))
except (ValueError,TypeError):return error('Durée invalide.')
@ -182,7 +196,7 @@ def create_invitation(request):
# not on invitation links (the private space always has its 50-Go cap).
token=secrets.token_urlsafe(32)
inv=SpaceInvitation.objects.create(parent_space=parent,created_by=request.user,
token_hash=hash_token(token),expires_at=timezone.now()+timedelta(days=days),**fields)
token_hash=hash_token(token),expires_at=timezone.now()+timedelta(days=days),permissions=rights,**fields)
# Returned exactly once; DB stores only SHA-256.
return Response({**invitation_info(inv),'token':token},status=201)
@ -221,6 +235,7 @@ def invitation_preview(request,token):
'can_view_parent':inv.can_view_parent,
'can_write_parent':inv.can_write_parent,
'can_invite':inv.can_invite,
'permissions':inv.permissions if inv.permissions is not None else legacy_rights(write=inv.can_write_parent, invite=inv.can_invite),
'expires_at':inv.expires_at.isoformat()})
@ -249,11 +264,11 @@ def accept_invitation(request,token):
with transaction.atomic():
inv=get_valid_invitation(token,lock=True)
if inv is None:return error('Invitation invalide ou déjà utilisée.',404)
enforce_delegation(inv.created_by,inv.parent_space,view=True,
write=inv.can_write_parent,invite=inv.can_invite)
rights=inv.permissions if inv.permissions is not None else legacy_rights(write=inv.can_write_parent,invite=inv.can_invite)
assert_delegation(space_rights(inv.created_by,inv.parent_space),rights)
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
SpaceGrant.objects.create(user=user,space=inv.parent_space,
can_write=inv.can_write_parent,can_invite=inv.can_invite)
can_write=inv.can_write_parent,can_invite=inv.can_invite,permissions=rights)
space=inv.parent_space
ensure_private_space(user)
inv.used_by=user;inv.used_at=timezone.now()
@ -268,7 +283,7 @@ def accept_invitation(request,token):
# -----------------------------------------------------------------------------
def grant_info(grant):
user = grant.user
return {'id': grant.pk, 'user_id': user.pk, 'username': user.username,
return {'id': grant.pk, 'permissions': grant.permissions if grant.permissions is not None else legacy_rights(write=grant.can_write,invite=grant.can_invite), 'user_id': user.pk, 'username': user.username,
'first_name': user.first_name, 'last_name': user.last_name,
'can_write': grant.can_write, 'can_invite': grant.can_invite}
@ -321,11 +336,13 @@ def add_existing_user(request):
can_invite=request.data.get('can_invite', False)
if not isinstance(can_write, bool) or not isinstance(can_invite, bool):
return error('Permissions invalides.')
try:enforce_delegation(request.user,parent,write=can_write,invite=can_invite)
try:
rights = assert_delegation(space_rights(request.user,parent),
request.data['permissions'] if 'permissions' in request.data else legacy_rights(write=can_write,invite=can_invite))
except ValueError as exc:return error(str(exc),403)
with transaction.atomic():
grant,created = SpaceGrant.objects.select_for_update().get_or_create(
user=account, space=parent, defaults={'can_write': can_write,'can_invite':can_invite})
user=account, space=parent, defaults={'can_write': 'add' in rights and 'modify' in rights,'can_invite':'manage_access' in rights,'permissions':rights})
if not created:
return error('Cet utilisateur a déjà accès à cet espace.')
return Response(grant_info(grant),status=201)
@ -347,9 +364,14 @@ def edit_existing_user(request, grant_id):
if not isinstance(request.data[key],bool):
return error('Permission invalide.')
setattr(grant,key,request.data[key])
try:enforce_delegation(request.user,grant.space,write=grant.can_write,invite=grant.can_invite)
try:
rights=assert_delegation(space_rights(request.user,grant.space),
request.data['permissions'] if 'permissions' in request.data else legacy_rights(write=grant.can_write,invite=grant.can_invite))
except ValueError as exc:return error(str(exc),403)
grant.save(update_fields=['can_write','can_invite'])
grant.permissions=rights
grant.can_write='add' in rights and 'modify' in rights
grant.can_invite='manage_access' in rights
grant.save(update_fields=['can_write','can_invite','permissions'])
return Response(grant_info(grant))
@ -362,9 +384,10 @@ def create_shared_space(request):
quota=parse_quota(request.data.get('quota_bytes'))
parent_id=request.data.get('parent_space')
parent=DriveSpace.objects.get(pk=int(parent_id)) if parent_id else ensure_space(request.user)
if parent and quota is None and parent.quota_bytes is not None:quota=parent.quota_bytes
except (ValueError, TypeError) as exc:return error(str(exc))
except DriveSpace.DoesNotExist:return error('Espace parent introuvable.',404)
if not manager(request.user,parent):return error('Création interdite dans cet espace.',403)
if not has_space_right(request.user,parent,'create_spaces'):return error('Création interdite dans cet espace.',403)
with transaction.atomic():
with locked_quota_tree(parent):
try:assert_new_child_quota(parent,quota)
@ -377,7 +400,7 @@ def create_shared_space(request):
def edit_shared_space(request,space_id):
try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if space.private_owner_id or not (is_admin(request.user) or space.managed_by_id == request.user.pk or (space.user_id == request.user.pk and manager(request.user,space))):
if space.private_owner_id or not has_space_right(request.user,space,'manage_space'):
return error('Modification interdite.',403)
if request.method == 'DELETE':
return delete_shared_space(request, space_id)
@ -474,7 +497,7 @@ def delete_shared_space(request, space_id):
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):
if not admin and (not space.parent_id or not has_space_right(request.user,space,'manage_space')):
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.')

View File

@ -9,8 +9,9 @@ from rest_framework.decorators import api_view, parser_classes
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from .models import DriveItem, UploadSession
from .services import error, item_info, target_parent, valid_name, readable, writable
from .services import error, item_info, target_parent, writable_destination, valid_name, readable, writable
from .spaces import resolve_space, can_write_space
from .permissions import has_space_right
from .storage_layout import file_location, disk_path, assert_available, prepare_parent, NameConflict
from .quotas import QuotaExceeded, locked_quota_tree, assert_space_capacity
@ -19,8 +20,7 @@ from .quotas import QuotaExceeded, locked_quota_tree, assert_space_capacity
@parser_classes([MultiPartParser,FormParser])
def upload(request):
try:
space=resolve_space(request.user,request.data.get('space'),write=True)
parent=target_parent(request.data.get('parent'),request.user,space)
space,parent=writable_destination(request.user,request.data.get('space'),request.data.get('parent'))
except ValueError as ex: return error(str(ex))
except PermissionError as ex: return error(str(ex),403)
incoming=request.FILES.getlist('files')
@ -69,8 +69,7 @@ def upload_start(request):
try:
name=valid_name(request.data.get('name'))
size=int(request.data.get('size',-1))
space=resolve_space(request.user,request.data.get('space'),write=True)
parent=target_parent(request.data.get('parent'),request.user,space)
space,parent=writable_destination(request.user,request.data.get('space'),request.data.get('parent'))
except (TypeError,ValueError) as ex:return error(str(ex))
except PermissionError as ex:return error(str(ex),403)
if size<0 or size>settings.DRIVE_MAX_FILE_SIZE:return error('Taille de fichier non autorisée.',413)
@ -131,7 +130,7 @@ def upload_finish(request,upload_id):
src.touch(exist_ok=True)
if not src.exists() or src.stat().st_size!=job.total_size:
return error('Le fichier temporaire est incomplet.')
if not can_write_space(request.user,job.space) or (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user))):
if (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user,'add'))) or (not job.parent and not has_space_right(request.user,job.space,'add')):
return error('Dossier de destination indisponible.',403)
key=str(uuid.uuid4())
location=file_location(job.space,job.parent,job.name)

View File

@ -66,7 +66,8 @@ export const drive={
list:params=>api.get('/drive/items/',{params}).then(r=>r.data),
shareUsers:q=>api.get('/drive/shares/users/search/',{params:{q}}).then(r=>r.data.users),
itemShares:id=>api.get(`/drive/items/${id}/shares/`).then(r=>r.data),
shareItem:(id,user_id)=>api.post(`/drive/items/${id}/shares/`,{user_id}).then(r=>r.data),
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}/`),
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}/`),
@ -112,7 +113,7 @@ export const drive={
deleteSpace:(id,confirm_name)=>api.delete(`/drive/spaces/${id}/`,{data:{confirm_name}}).then(r=>r.data),
renameSpace:display_name=>api.patch('/drive/spaces/me/',{display_name}).then(r=>r.data),
searchUsers:(q,parent_space)=>api.get('/drive/spaces/users/search/',{params:{q,parent_space}}).then(r=>r.data.users),
addUser:(user_id,parent_space,can_write=false,can_invite=false)=>api.post('/drive/spaces/users/',{user_id,can_write,can_invite},{params:{parent_space}}).then(r=>r.data),
addUser:(user_id,parent_space,permissions)=>api.post('/drive/spaces/users/',{user_id,permissions},{params:{parent_space}}).then(r=>r.data),
grantPatch:(id,data)=>api.patch(`/drive/spaces/users/${id}/`,data).then(r=>r.data),
grantRemove:id=>api.delete(`/drive/spaces/users/${id}/`),
members:parent_space=>api.get('/drive/spaces/members/',{params:parent_space?{parent_space}:{}}).then(r=>r.data),
@ -125,4 +126,4 @@ export const drive={
}
export function message(error){let d=error?.response?.data;if(typeof d?.detail==='string')return d.detail;if(typeof d==='object' && d!==null)return Object.entries(d).map(([k,v])=>`${k}: ${Array.isArray(v)?v.join(', '):v}`).join(' · ');return 'Une erreur est survenue. Réessaie.'}
export const publicFileUrl = (token,id) => `${publicApi.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/`
export const publicFileUrl = (token,id,inline=false) => `${publicApi.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/${inline?'?inline=1':''}`

View File

@ -4,23 +4,24 @@ import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users
export const fileOptions = (item, trash) => {
if (!item) return []
if (trash) return item.can_write ? [
const rights = new Set(item.permissions || [])
if (trash) return rights.has('delete') ? [
{ id: 'restore', title: 'Restaurer', icon: RotateCcw },
{ id: 'confirmdelete', title: 'Supprimer définitivement', icon: Trash2, danger: true },
] : []
return [
{ id: 'open', title: 'Ouvrir', icon: Eye },
...(!item.is_folder ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []),
...(item.can_write ? [{ id: 'share', title: 'Partager', icon: Share2 }] : []),
...(item.can_write ? [
...(!item.is_folder && rights.has('download') ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []),
...(rights.has('share') ? [{ id: 'share', title: 'Partager', icon: Share2 }] : []),
...(rights.has('modify') ? [
{ id: 'rename', title: 'Renommer', icon: Pencil },
{ id: 'move', title: 'Déplacer', icon: FolderInput },
{ id: 'trash', title: 'Mettre à la corbeille', icon: Trash2, danger: true },
] : []),
...(rights.has('delete') ? [{id:'trash',title:'Mettre à la corbeille',icon:Trash2,danger:true}] : []),
]
}
export const spaceOptions = (space) => (space?.can_invite || space?.can_delete) ? [
export const spaceOptions = (space) => (space?.can_invite || space?.can_delete || space?.can_edit) ? [
...(space.can_edit ? [{id:'manage',title:'Paramètres de l’espace',icon:Settings2}] : []),
...(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}] : []),

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import ContextActions, {spaceOptions, mobileDots} from './ContextActions'
import { MoreHorizontal, Plus } from 'lucide-react'
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from 'lucide-react'
import { Database, FolderPlus, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
import { sections } from '@/config/navigation'
import { fmtSize } from '@/lib/drive-format'
@ -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 || space.can_delete))) closeSpaceMenu()
if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && (space.can_invite || space.can_delete || space.can_edit))) closeSpaceMenu()
},[spaces,spaceMenu,closeSpaceMenu])
const showSpaceMenu=(space,x,y)=>{
const options=spaceOptions(space)
@ -46,8 +46,8 @@ export default function DriveSidebar({ controller, user, onLogout }) {
const orphaned = pendingUploads.filter(job => !currentSessions.has(job.id))
const personal = spaces.find(space => space.is_private && space.user_id === user.id)
const shared = spaces.filter(space => !space.is_private)
const manageable=shared.filter(space=>space.can_invite)
const createSpace=()=>{setSpaceDialog({mode:'create',parentId:activeSpace?.can_invite&&!activeSpace.is_private ? activeSpaceId : manageable[0]?.id});setSidebar(false)}
const manageable=shared.filter(space=>(space.permissions || []).includes('create_spaces'))
const createSpace=()=>{setSpaceDialog({mode:'create',parentId:activeSpace?.permissions?.includes('create_spaces')&&!activeSpace.is_private ? activeSpaceId : manageable[0]?.id});setSidebar(false)}
const newFolder = () => {
setValue('')
@ -86,18 +86,8 @@ export default function DriveSidebar({ controller, user, onLogout }) {
<p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">Espaces partagés</p>
{manageable.length>0 && <Button type="button" size="icon-sm" variant="ghost" title="Créer un espace partagé" aria-label="Créer un espace partagé" onClick={createSpace}><Plus className="size-4" /></Button>}
</div>
<div className="space-y-1 pb-4">
{shared.map(space => <div key={space.id} className={`group flex min-h-10 items-center rounded-xl ${activeSpaceId===space.id ? 'bg-accent text-primary font-semibold':'text-muted-foreground hover:bg-muted'}`}
onContextMenu={event=>{if(space.can_invite||space.can_delete){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}>
<button type="button" onClick={()=>switchSpace(space.id)}
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm">
{space.can_write ? <FolderOpen className="size-4 shrink-0"/>:<FolderLock className="size-4 shrink-0"/>}
<span className="min-w-0 flex-1 truncate">{space.display_name}</span>
</button>
{(space.can_invite || space.can_delete) && <Button type="button" variant="ghost" size="icon-sm" title="Gérer l’espace"
aria-label={`Gérer ${space.display_name}`} className="mr-1 shrink-0 md:hidden"
onClick={event=>mobileDots(event, data=>setSpaceMenu({space,...data}),spaceOptions(space))}><MoreHorizontal className="size-4" /></Button>}
</div>)}
<div className="space-y-0.5 pb-4">
{shared.filter(space=>!shared.some(ancestor=>ancestor.id===space.parent_id)).map(space=><SpaceTreeNode key={space.id} space={space} shared={shared} depth={0} activeSpaceId={activeSpaceId} switchSpace={switchSpace} showSpaceMenu={showSpaceMenu} setSpaceMenu={setSpaceMenu}/>) }
</div>
<p className="mb-3 px-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Explorer
@ -175,3 +165,20 @@ export default function DriveSidebar({ controller, user, onLogout }) {
</aside>
)
}
function SpaceTreeNode({space,shared,depth,activeSpaceId,switchSpace,showSpaceMenu,setSpaceMenu}) {
const [expanded,setExpanded]=useState(true)
const children=shared.filter(node=>node.parent_id===space.id && node.id!==space.id)
return <div>
<div className={`group flex min-h-10 items-center rounded-xl ${activeSpaceId===space.id?'bg-accent font-semibold text-primary':'text-muted-foreground hover:bg-muted'}`}
style={{paddingLeft:`${Math.min(depth,10)*12}px`}}
onContextMenu={event=>{if(space.can_invite||space.can_delete||space.can_edit){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}>
{children.length>0 ? <button type="button" className="grid size-7 shrink-0 place-items-center" aria-label={expanded?'Réduire':'Déplier'} onClick={()=>setExpanded(!expanded)}>{expanded?<ChevronDown className="size-4"/>:<ChevronRight className="size-4"/>}</button> : <span className="w-7 shrink-0" />}
<button type="button" onClick={()=>switchSpace(space.id)} className="flex min-w-0 flex-1 items-center gap-2 py-2 pr-1 text-left text-sm">
{space.can_write?<FolderOpen className="size-4 shrink-0"/>:<FolderLock className="size-4 shrink-0"/>}<span className="truncate">{space.display_name}</span>
</button>
{(space.can_invite||space.can_delete||space.can_edit) && <Button type="button" variant="ghost" size="icon-sm" className="mr-1 shrink-0 md:hidden" title={`Gérer ${space.display_name}`} onClick={event=>mobileDots(event,data=>setSpaceMenu({space,...data}),spaceOptions(space))}><MoreHorizontal className="size-4"/></Button>}
</div>
{expanded && children.map(node=><SpaceTreeNode key={node.id} space={node} shared={shared} depth={depth+1} activeSpaceId={activeSpaceId} switchSpace={switchSpace} showSpaceMenu={showSpaceMenu} setSpaceMenu={setSpaceMenu}/>) }
</div>
}

View File

@ -0,0 +1,32 @@
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
export const RIGHTS = [
['view','Consulter'], ['download','Télécharger'], ['add','Ajouter'],
['modify','Modifier'], ['delete','Supprimer'], ['share','Partager'],
['manage_access','Gérer les accès'], ['create_spaces','Créer des espaces'], ['manage_space','Gérer l’espace'],
]
export const PRESETS = {
reader: ['view','download'],
contributor: ['view','download','add','modify'],
manager: RIGHTS.map(([id])=>id),
}
export default function PermissionsEditor({value=[],onChange,maxRights=RIGHTS.map(([id])=>id),compact=false}) {
const maximum = new Set(maxRights || [])
const selected = new Set(value || [])
const update = name => {
if(!maximum.has(name))return
if(name==='view' && selected.has('view'))return
if(selected.has(name))selected.delete(name)
else {selected.add(name);selected.add('view')}
onChange(RIGHTS.map(([id])=>id).filter(k=>selected.has(k)&&maximum.has(k)))
}
return <div className="space-y-3">
<div className="flex flex-wrap gap-1.5">{Object.entries(PRESETS).map(([key,rights])=><Button key={key} type="button" size="sm" variant="outline" onClick={()=>onChange(rights.filter(right=>maximum.has(right)))}>{({reader:'Lecteur',contributor:'Contributeur',manager:'Gestionnaire'})[key]}</Button>)}</div>
<div className={compact?'grid grid-cols-2 gap-2':'grid gap-2 sm:grid-cols-2'}>
{RIGHTS.filter(([key])=>maximum.has(key)).map(([key,label])=><label key={key} className="flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<Checkbox checked={selected.has(key)} disabled={key==='view'} onCheckedChange={()=>update(key)} />{label}
</label>)}
</div>
</div>
}

View File

@ -10,6 +10,7 @@ const ActionButton = ({ icon: Icon, children, destructive = false, ...props }) =
export default function ItemActionsDialog({ controller: c }) {
const { modal, setModal, action, handleAction, isTrash } = c
const rights = new Set(action?.permissions || [])
return (
<Dialog open={modal === 'actions'} onOpenChange={open => { if (!open) setModal(null) }}>
<DialogContent className="w-[calc(100vw-2rem)] max-h-[85dvh] min-w-0 overflow-y-auto sm:max-w-md">
@ -20,14 +21,14 @@ export default function ItemActionsDialog({ controller: c }) {
<div className="grid min-w-0 grid-cols-1 gap-2 min-[400px]:grid-cols-2">
{action && <>
{!isTrash && <ActionButton icon={Eye} onClick={() => handleAction('open')}>Ouvrir</ActionButton>}
{!action.is_folder && <ActionButton icon={Download} onClick={() => handleAction('download')}>Télécharger</ActionButton>}
{action.can_write && !isTrash && <>
<ActionButton icon={Share2} onClick={() => handleAction('share')}>Partager</ActionButton>
{!action.is_folder && rights.has('download') && <ActionButton icon={Download} onClick={() => handleAction('download')}>Télécharger</ActionButton>}
{!isTrash && rights.has('share') && <ActionButton icon={Share2} onClick={() => handleAction('share')}>Partager</ActionButton>}
{!isTrash && rights.has('modify') && <>
<ActionButton icon={Pencil} onClick={() => handleAction('rename')}>Renommer</ActionButton>
<ActionButton icon={FolderInput} onClick={() => handleAction('move')}>Déplacer</ActionButton>
<ActionButton icon={Trash2} destructive onClick={() => handleAction('trash')}>Corbeille</ActionButton>
</>}
{action.can_write && isTrash && <>
{!isTrash && rights.has('delete') && <ActionButton icon={Trash2} destructive onClick={() => handleAction('trash')}>Corbeille</ActionButton>}
{rights.has('delete') && isTrash && <>
<ActionButton icon={RotateCcw} onClick={() => handleAction('restore')}>Restaurer</ActionButton>
<ActionButton icon={Trash2} destructive onClick={() => handleAction('confirmdelete')}>Supprimer</ActionButton>
</>}

View File

@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react'
export default function ItemFormDialog({controller: c}) {
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
return (
<Dialog open={modal==='folder'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription className={modal==='move' ? undefined : 'sr-only'}>{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : 'Nouveau nom'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.can_write).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
<Dialog open={modal==='folder'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription className={modal==='move' ? undefined : 'sr-only'}>{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : 'Nouveau nom'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.permissions?.includes('add')).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
)
}

View File

@ -25,7 +25,7 @@ export default function PreviewDialog({controller: c}) {
<div className="flex min-h-40 max-h-[72dvh] items-center justify-center overflow-auto rounded-xl bg-muted/60">
{preview && <Preview item={preview} url={previewUrl} onError={()=>setPreviewUrl('unsupported')}/>}
</div>
<DialogFooter>{preview && <Button variant="outline" onClick={()=>openEntry(preview)}><RefreshCcw/> Réessayer</Button>}<Button variant="outline" onClick={close}>Fermer</Button>{preview && <Button onClick={()=>download(preview)}><Download/> Télécharger</Button>}</DialogFooter>
<DialogFooter>{preview && <Button variant="outline" onClick={()=>openEntry(preview)}><RefreshCcw/> Réessayer</Button>}<Button variant="outline" onClick={close}>Fermer</Button>{preview?.permissions?.includes('download') && <Button onClick={()=>download(preview)}><Download/> Télécharger</Button>}</DialogFooter>
</DialogContent>
</Dialog>
}

View File

@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { drive, message } from '@/api/client'
import PermissionsEditor, {PRESETS} from '@/components/drive/PermissionsEditor'
export default function ShareItemDialog({ controller }) {
const { modal, setModal, action } = controller
@ -15,6 +16,7 @@ export default function ShareItemDialog({ controller }) {
const [link, setLink] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [rights,setRights]=useState(PRESETS.reader)
const reload = async (id = itemId) => {
if (!id) return
const data = await drive.itemShares(id)
@ -40,7 +42,7 @@ export default function ShareItemDialog({ controller }) {
finally{setBusy(false)}
}
const addUser = user => perform(async()=>{
await drive.shareItem(itemId,user.id)
await drive.shareItem(itemId,user.id,rights)
setQuery('');setResults([])
})
const createLink = () => perform(async()=>{
@ -57,6 +59,7 @@ export default function ShareItemDialog({ controller }) {
<div className="space-y-5">
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold"><UserPlus className="size-4"/> Partager avec un utilisateur</h3>
<PermissionsEditor compact value={rights} onChange={setRights} maxRights={action?.permissions || ['view','download']} />
<Input placeholder="Rechercher un utilisateur…" value={query} onChange={e=>setQuery(e.target.value)}/>
{results.length>0 && <div className="max-h-44 overflow-y-auto rounded-lg border p-1">
{results.map(user=><button key={user.id} type="button" disabled={busy || record.users.some(u=>u.user_id===user.id)}
@ -64,11 +67,11 @@ export default function ShareItemDialog({ controller }) {
onClick={()=>addUser(user)}>{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} · @{user.username}</button>)}
</div>}
{record.users.map(user=><div key={user.id} className="flex items-center justify-between gap-2 rounded-lg border p-2 text-sm">
<span className="min-w-0 truncate">{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username})</span>
<div className="min-w-0 flex-1 space-y-2"><span className="block truncate">{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username})</span><PermissionsEditor compact value={user.permissions} maxRights={action?.permissions || ['view','download']} onChange={permissions=>perform(()=>drive.shareItemPermissions(itemId,user.id,permissions))}/></div>
<Button size="icon-sm" variant="ghost" disabled={busy} title="Retirer l’accès" onClick={()=>perform(()=>drive.unshareItem(itemId,user.id))}><Trash2 className="size-4"/></Button>
</div>)}
</section>
<section className="space-y-3 border-t pt-4">
{action?.permissions?.includes('download') && <section className="space-y-3 border-t pt-4">
<h3 className="flex items-center gap-2 text-sm font-semibold"><Link2 className="size-4"/> Accès public</h3>
<Button type="button" variant="outline" disabled={busy} onClick={createLink}>{busy?<LoaderCircle className="animate-spin"/>:<Link2/>} Générer un lien public</Button>
{link && <div className="flex items-center gap-2"><Input readOnly value={link} aria-label="Lien de partage public"/><Button variant="outline" size="icon" onClick={copy} title="Copier"><Copy/></Button></div>}
@ -76,7 +79,7 @@ export default function ShareItemDialog({ controller }) {
<span>Lien créé le {new Date(publicLink.created_at).toLocaleDateString('fr-FR')}</span>
<Button size="icon-sm" variant="ghost" disabled={busy} title="Révoquer le lien" onClick={()=>perform(()=>drive.revokePublic(itemId,publicLink.id))}><Trash2 className="size-4"/></Button>
</div>)}</div>}
</section>
</section>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>
</DialogContent>

View File

@ -11,7 +11,7 @@ export default function SpaceSettingsDialog({controller: c}) {
const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, loadSpaces, switchSpace, setError, setNote, setManagedSpaceId} = c
const manage = spaceDialog?.mode === 'manage'
const current = spaces.find(space => space.id === spaceDialog?.spaceId)
const managed = spaces.filter(space => !space.is_private && space.can_invite)
const managed = spaces.filter(space => !space.is_private && space.permissions?.includes('create_spaces'))
const [name,setName] = useState('')
const [quota,setQuota] = useState('')
const [parent,setParent] = useState('')
@ -19,7 +19,7 @@ export default function SpaceSettingsDialog({controller: c}) {
const [localError,setLocalError] = useState('')
useEffect(() => {
setName(manage ? current?.display_name || '' : '')
setQuota(manage ? fromQuota(current?.quota_bytes) : '')
setQuota(manage ? fromQuota(current?.quota_bytes) : fromQuota(managed.find(sp=>sp.id===(spaceDialog?.parentId || managed[0]?.id))?.quota_bytes))
setParent(String(manage ? current?.parent_id || '' : spaceDialog?.parentId || managed[0]?.id || ''))
setLocalError('')
}, [spaceDialog?.mode,spaceDialog?.spaceId,spaceDialog?.parentId,current?.display_name,current?.quota_bytes,current?.parent_id,managed[0]?.id])
@ -52,7 +52,7 @@ export default function SpaceSettingsDialog({controller: c}) {
</DialogHeader>
<form onSubmit={save} className="space-y-4">
{!manage && <div className="space-y-2"><Label htmlFor="space-parent">Espace parent</Label>
<select id="space-parent" value={parent} onChange={e=>setParent(e.target.value)} required
<select id="space-parent" value={parent} onChange={e=>{setParent(e.target.value);setQuota(fromQuota(managed.find(sp=>sp.id===Number(e.target.value))?.quota_bytes))}} required
className="h-10 w-full rounded-md border bg-background px-3 text-sm">
{managed.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)}
</select></div>}

View File

@ -13,6 +13,7 @@ export function useDriveController(user) {
const [parent, setParent] = useState(null)
const [trail, setTrail] = useState([])
const [items, setItems] = useState([])
const [sharedFolderAccess,setSharedFolderAccess]=useState({can_write:false,space_id:null,parentId:null})
const [stats, setStats] = useState(null)
const [pendingUploads, setPendingUploads] = useState([])
const [query, setQuery] = useState('')
@ -53,7 +54,7 @@ export function useDriveController(user) {
}, [query])
const activeSpace = spaces.find(s => s.id === activeSpaceId)
const canWrite = view === 'files' && Boolean(activeSpace?.can_write)
const canWrite = (view === 'files' && Boolean(activeSpace?.permissions?.includes('add'))) || (view==='shared' && Boolean(parent && sharedFolderAccess.parentId===parent && sharedFolderAccess.can_write))
const loadSpaces = useCallback(async () => {
try {
const data = await drive.spaces()
@ -89,6 +90,7 @@ export function useDriveController(user) {
const data = await drive.list(params)
if (sequence !== requestSequence.current) return
setItems(data.items)
if(view==='shared')setSharedFolderAccess({can_write:data.can_write,space_id:data.space_id,parentId:parent})
setTrail(data.breadcrumb || [])
if (data.limited && !silent) setNote('La liste affiche les 500 premiers résultats. Utilise la recherche pour affiner.')
if (!silent) setError('')
@ -191,7 +193,7 @@ export function useDriveController(user) {
const uploadFiles = incoming => {
if (!canWrite || view === 'trash') return
uploads.addFiles(incoming, view === 'files' ? parent : null, activeSpaceId)
uploads.addFiles(incoming, parent, view === 'shared' ? sharedFolderAccess.space_id : activeSpaceId)
if (filesInput.current) filesInput.current.value = ''
}
@ -275,7 +277,7 @@ export function useDriveController(user) {
const actionSubmit = event => {
event.preventDefault()
if (modal === 'folder') return run(() => drive.folder(value, parent, activeSpaceId))
if (modal === 'folder') return run(() => drive.folder(value,parent,view==='shared'?sharedFolderAccess.space_id:activeSpaceId))
if (modal === 'rename') return run(() => drive.patch(action.id, { name: value }))
if (modal === 'move') return run(() => drive.patch(action.id, { parent: moveFolder }))
}

View File

@ -3,28 +3,17 @@ import { Check, Copy, KeyRound, Link2, ShieldCheck, Trash2, Users } from 'lucide
import { drive, message } from '@/api/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import QuotaField from '@/components/drive/QuotaField'
import {fromQuota, toQuota} from '@/lib/quota-utils'
import {fmtSize} from '@/lib/drive-format'
const options = [
{ key: 'can_view_parent', label: 'Voir les fichiers du parent' },
{ key: 'can_write_parent', label: 'Modifier les fichiers du parent' },
{ key: 'can_invite', label: 'Créer des sous-utilisateurs' },
]
function Permissions({ value, onChange, disabled = false, maxRights = {}, fixedRead = false }) {
return <div className="space-y-2">{options.map(opt => <label key={opt.key} className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1 text-sm">
<Checkbox disabled={disabled || maxRights[opt.key] === false || (fixedRead && opt.key === 'can_view_parent')} checked={Boolean(value[opt.key])} onCheckedChange={checked => onChange({ ...value, [opt.key]:Boolean(checked), ...(opt.key==='can_view_parent' && !checked ? {can_write_parent:false} : opt.key==='can_write_parent' && checked ? {can_view_parent:true} : {}) })}/>{opt.label}
</label>)}</div>
}
import PermissionsEditor, {PRESETS} from '@/components/drive/PermissionsEditor'
export default function MembersPage({ controller, user, embedded = false }) {
const { spaces, activeSpaceId, loadSpaces, managedSpaceId, setManagedSpaceId, setSpaceDialog } = controller
const [parentId, setParentId] = useState(managedSpaceId || user.space_id)
const [record, setRecord] = useState(null)
const reloadSequence = useRef(0)
const [rights, setRights] = useState({can_view_parent:true,can_write_parent:false,can_invite:false})
const [rights, setRights] = useState(PRESETS.reader)
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [busy, setBusy] = useState(false)
@ -36,7 +25,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
const [userResults, setUserResults] = useState([])
const [searching, setSearching] = useState(false)
const [selectedAccount, setSelectedAccount] = useState(null)
const [directRights, setDirectRights] = useState({can_write:false,can_invite:false})
const [directRights, setDirectRights] = useState(PRESETS.reader)
const mine = spaces.find(s => s.id === user.space_id)
const managed = spaces.filter(s => !s.is_private && s.can_invite && (!embedded || s.id === managedSpaceId))
@ -74,7 +63,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
const create = async () => {
setBusy(true);setError('');setUrl('')
try {
const inv = await drive.invite({ ...rights, expires_days:Number(expires), parent_space:parentId })
const inv = await drive.invite({ permissions:rights, expires_days:Number(expires), parent_space:parentId })
setUrl(`${window.location.origin}/invite/${inv.token}`)
setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.')
await reload()
@ -114,7 +103,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
if (!selectedAccount) return
setBusy(true); setError('')
try {
await drive.addUser(selectedAccount.id,parentId,directRights.can_write,directRights.can_invite)
await drive.addUser(selectedAccount.id,parentId,directRights)
setInfo(`Accès accordé à ${selectedAccount.first_name} ${selectedAccount.last_name}`.trim())
setSelectedAccount(null);setUserQuery('');setUserResults([])
await reload();await loadSpaces()
@ -179,17 +168,16 @@ export default function MembersPage({ controller, user, embedded = false }) {
</div>}
{selectedAccount && <div className="space-y-3 rounded-xl border p-3">
<p className="text-sm">Utilisateur sélectionné : <strong>{[selectedAccount.first_name,selectedAccount.last_name].filter(Boolean).join(' ')}</strong> (@{selectedAccount.username})</p>
<label className="flex items-center gap-2 text-sm"><Checkbox disabled={!selectedManagedSpace?.can_write} checked={directRights.can_write} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_write:!!v}))}/> Autoriser les modifications dans cet espace</label>
<label className="flex items-center gap-2 text-sm"><Checkbox disabled={!selectedManagedSpace?.can_invite} checked={directRights.can_invite} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_invite:!!v}))}/> Autoriser la gestion des utilisateurs de cet espace</label>
<PermissionsEditor value={directRights} onChange={setDirectRights} maxRights={selectedManagedSpace?.permissions}/>
<Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button>
</div>}
{!!directGrants.length && <div className="space-y-2 border-t pt-3"><h3 className="text-sm font-semibold">Utilisateurs ajoutés directement</h3>
{directGrants.map(g=><DirectMember key={g.id} grant={g} maxRights={selectedManagedSpace} busy={busy} save={data=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)}
{directGrants.map(g=><DirectMember key={g.id} grant={g} maxRights={selectedManagedSpace?.permissions} busy={busy} save={data=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)}
</div>}
</div>
<div className="rounded-2xl border bg-card p-5 space-y-4">
<div className="flex items-center gap-2 font-semibold"><KeyRound className="size-5 text-primary"/> Créer une invitation</div>
<Permissions value={rights} onChange={setRights} fixedRead maxRights={{can_view_parent:true,can_write_parent:!!selectedManagedSpace?.can_write,can_invite:!!selectedManagedSpace?.can_invite}}/>
<PermissionsEditor value={rights} onChange={setRights} maxRights={selectedManagedSpace?.permissions}/>
<label className="block text-sm">Expiration du lien
<select value={expires} onChange={e=>setExpires(e.target.value)} className="ml-3 rounded-lg border bg-background px-2 py-1"><option value="1">1 jour</option><option value="7">7 jours</option><option value="14">14 jours</option><option value="30">30 jours</option></select>
</label>
@ -209,9 +197,9 @@ export default function MembersPage({ controller, user, embedded = false }) {
}
function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) {
const [rights,setRights]=useState({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite})
const [rights,setRights]=useState(child.parent_permissions || PRESETS.reader)
const [quota,setQuota]=useState(fromQuota(child.quota_bytes))
useEffect(()=>{setRights({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite});setQuota(fromQuota(child.quota_bytes))},[child])
useEffect(()=>{setRights(child.parent_permissions || PRESETS.reader);setQuota(fromQuota(child.quota_bytes))},[child])
if (!child.has_account) return <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-4">
<div className="min-w-0">
<div className="text-sm font-semibold">{child.display_name}</div>
@ -219,7 +207,7 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) {
</div>
{child.can_invite && <Button type="button" size="sm" variant="outline" disabled={busy} onClick={onManage}>Gérer ce sous-espace</Button>}
</div>
return <div className="rounded-xl border p-4"><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div><div className="font-medium">{child.display_name}</div><div className="text-xs text-muted-foreground">{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" disabled={busy} onClick={()=>{try{save({...rights,...(quota === fromQuota(child.quota_bytes) ? {} : {quota_bytes:toQuota(quota)})})}catch(e){onInvalid(e.message)}}}><Check className="size-4"/> Appliquer</Button><Button size="sm" variant="outline" disabled={busy} onClick={()=>save({is_active:!child.is_active})}>{child.is_active?'Suspendre':'Réactiver'}</Button></div></div><Permissions value={rights} onChange={setRights} maxRights={{can_write_parent:!!maxRights?.can_write,can_invite:!!maxRights?.can_invite}}/>
return <div className="rounded-xl border p-4"><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div><div className="font-medium">{child.display_name}</div><div className="text-xs text-muted-foreground">{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" disabled={busy} onClick={()=>{try{save({permissions:rights,...(quota === fromQuota(child.quota_bytes) ? {} : {quota_bytes:toQuota(quota)})})}catch(e){onInvalid(e.message)}}}><Check className="size-4"/> Appliquer</Button><Button size="sm" variant="outline" disabled={busy} onClick={()=>save({is_active:!child.is_active})}>{child.is_active?'Suspendre':'Réactiver'}</Button></div></div><PermissionsEditor value={rights} onChange={setRights} maxRights={maxRights?.permissions}/>
<div className="mt-3 border-t pt-3 space-y-3"><QuotaField id={`quota-${child.id}`} value={quota} onChange={setQuota} disabled={busy}/>
<p className="text-xs text-muted-foreground">Utilisé : {fmtSize(child.used_bytes || 0)}{child.reserved_bytes > 0 ? ` · Transferts réservés : ${fmtSize(child.reserved_bytes)}` : ''} · Disponible : {child.available_bytes == null ? 'Sans limite' : fmtSize(child.available_bytes)}</p>
</div></div>
@ -227,16 +215,12 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) {
function DirectMember({grant,save,remove,busy,maxRights}) {
const [canWrite,setCanWrite] = useState(grant.can_write)
const [canInvite,setCanInvite] = useState(grant.can_invite)
useEffect(()=>{setCanWrite(grant.can_write);setCanInvite(grant.can_invite)},[grant])
return <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3">
<div className="min-w-36"><div className="text-sm font-semibold">{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}</div><div className="text-xs text-muted-foreground">@{grant.username}</div></div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs"><Checkbox disabled={!maxRights?.can_write} checked={canWrite} onCheckedChange={v=>setCanWrite(!!v)}/> Écriture</label>
<label className="flex items-center gap-2 text-xs"><Checkbox disabled={!maxRights?.can_invite} checked={canInvite} onCheckedChange={v=>setCanInvite(!!v)}/> Invitations</label>
<Button size="sm" variant="outline" disabled={busy} onClick={()=>save({can_write:canWrite,can_invite:canInvite})}>Enregistrer</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={remove} title="Retirer l’accès"><Trash2 className="size-4" /></Button>
</div>
const [permissions,setPermissions]=useState(grant.permissions || PRESETS.reader)
useEffect(()=>setPermissions(grant.permissions || PRESETS.reader),[grant])
return <div className="space-y-3 rounded-xl border p-3">
<div className="flex items-center justify-between gap-3"><div><p className="text-sm font-semibold">{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}</p><p className="text-xs text-muted-foreground">@{grant.username}</p></div>
<Button size="icon-sm" variant="ghost" disabled={busy} onClick={remove} aria-label="Retirer l’accès"><Trash2 className="size-4"/></Button></div>
<PermissionsEditor value={permissions} maxRights={maxRights} onChange={setPermissions}/>
<Button size="sm" variant="outline" disabled={busy} onClick={()=>save({permissions})}>Enregistrer les droits</Button>
</div>
}

View File

@ -1,41 +1,72 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowLeft, Download, File, Folder, LoaderCircle } from 'lucide-react'
import { ArrowLeft, Download, File, Folder, FolderOpen, LoaderCircle, Eye, X, Image, FileText, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { drive, message, publicFileUrl } from '@/api/client'
import { fmtSize } from '@/lib/drive-format'
function typeOf(item) {
const n = (item.name || '').toLowerCase(), m=(item.mime_type || '').toLowerCase()
if(m.startsWith('image/') && m!=='image/svg+xml' || /\.(png|jpe?g|gif|webp|bmp|avif)$/.test(n)) return 'image'
if(m==='application/pdf' || n.endsWith('.pdf'))return 'pdf'
if(m.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/.test(n))return 'video'
if(m.startsWith('audio/') || /\.(mp3|ogg|wav|m4a|flac|aac)$/.test(n))return 'audio'
if(m.startsWith('text/') || /\.(txt|md|json|csv|xml|log|py|js|css)$/.test(n))return 'text'
return null
}
function Viewer({item,token}) {
const kind=typeOf(item)
const src=publicFileUrl(token,item.id,true)
if(kind==='image')return <img className="mx-auto max-h-[72vh] max-w-full rounded-lg object-contain" alt={item.name} src={src}/>
if(kind==='pdf')return <iframe title={item.name} src={src} className="h-[70vh] w-full rounded-xl bg-white"/>
if(kind==='video')return <video controls playsInline preload="metadata" src={src} className="max-h-[70vh] w-full rounded-xl"/>
if(kind==='audio')return <audio controls src={src} className="w-full"/>
if(kind==='text')return <iframe sandbox="" title={item.name} src={src} className="h-[65vh] w-full rounded-xl bg-white"/>
return <div className="p-10 text-center text-muted-foreground">Aperçu indisponible pour ce format.</div>
}
export default function PublicSharePage({token}) {
const [parent,setParent]=useState(null)
const [data,setData]=useState(null)
const [selected,setSelected]=useState(null)
const [error,setError]=useState('')
const [loading,setLoading]=useState(true)
const load=useCallback(async()=>{
setLoading(true)
try { setData(await drive.publicBrowse(token,parent));setError('') }
catch(e){setError(e?.response?.status===404 ? 'Ce lien est invalide ou n’est plus disponible.' : message(e))}
catch(e){setError(e?.response?.status===404?'Ce lien a expiré ou n’est plus disponible.':message(e))}
finally{setLoading(false)}
},[token,parent])
useEffect(()=>{load()},[load])
const folderBack = () => {
if(!data?.breadcrumb?.length || data.breadcrumb.length<=1) {setParent(null);return}
setParent(data.breadcrumb.at(-2).id)
}
return <div className="min-h-dvh bg-background px-4 py-10">
<main className="mx-auto max-w-3xl rounded-2xl border bg-card p-5 shadow-sm sm:p-8">
<div className="mb-6 flex items-center gap-3"><img src="/icon.png" alt="GoodDrive" className="size-10"/><div><h1 className="text-xl font-bold">GoodDrive</h1><p className="text-sm text-muted-foreground">Fichier partagé publiquement</p></div></div>
{loading ? <p className="flex items-center gap-2"><LoaderCircle className="size-4 animate-spin"/> Chargement…</p> : error ? <p className="text-destructive" role="alert">{error}</p> : data && <>
<h2 className="mb-5 break-all text-lg font-semibold">{data.root.name}</h2>
{data.root.is_folder && <>
<div className="mb-3 flex items-center gap-2">
{!!parent && <Button variant="outline" size="sm" onClick={folderBack}><ArrowLeft className="size-4"/> Retour</Button>}
<span className="truncate text-sm text-muted-foreground">{data.breadcrumb?.map(node=>node.name).join(' / ')}</span>
useEffect(()=>{if(data?.root && !data.root.is_folder)setSelected(data.root)},[data?.root?.id])
const previous=()=>setParent(data?.breadcrumb?.length>1?data.breadcrumb.at(-2).id:null)
const items=data?.items || []
return <div className="min-h-dvh bg-muted/25 px-4 py-5 sm:py-12">
<div className="mx-auto max-w-5xl space-y-5">
<header className="flex items-center gap-3 px-1"><img src="/icon.png" alt="GoodDrive" className="size-12 rounded-xl"/><div><h1 className="text-xl font-bold tracking-tight">GoodDrive</h1><p className="text-sm text-muted-foreground">Un partage, tout simplement.</p></div></header>
<main className="overflow-hidden rounded-3xl border bg-card shadow-sm">
{loading ? <div className="flex min-h-72 items-center justify-center gap-3"><LoaderCircle className="size-5 animate-spin"/> Chargement…</div>
: error ? <div className="p-10 text-center text-destructive" role="alert">{error}</div>
: data && <>
<div className="border-b bg-muted/20 px-5 py-6 sm:px-8">
<div className="mb-3 flex items-center gap-2 text-xs font-medium text-muted-foreground"><ShieldCheck className="size-4"/> Partage public en lecture seule</div>
<h2 className="break-words text-2xl font-semibold tracking-tight">{data.root.name}</h2>
{data.root.is_folder ? <p className="mt-2 text-sm text-muted-foreground">{parent ? data.breadcrumb?.map(i=>i.name).join(' / ') : `${items.length} élément${items.length>1?'s':''}`}</p> : <p className="mt-2 text-sm text-muted-foreground">{fmtSize(data.root.size)}</p>}
</div>
<div className="divide-y rounded-lg border">{data.items?.length ? data.items.map(item=><div key={item.id} className="flex items-center justify-between gap-3 p-3">
<div className="flex min-w-0 items-center gap-2">{item.is_folder?<Folder className="size-5 shrink-0"/>:<File className="size-5 shrink-0"/>}<span className="truncate text-sm">{item.name}</span></div>
{item.is_folder ? <Button size="sm" variant="outline" onClick={()=>setParent(item.id)}>Ouvrir</Button> : <a className="grid size-8 place-items-center rounded-lg border hover:bg-muted" title="Télécharger" href={publicFileUrl(token,item.id)}><Download className="size-4"/></a>}
</div>) : <p className="p-4 text-sm text-muted-foreground">Ce dossier est vide.</p>}</div>
</>}
{!data.root.is_folder && <a className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground" href={publicFileUrl(token,data.root.id)}><Download className="size-4"/> Télécharger {data.root.name}</a>}
{data.root.is_folder && <div className="px-3 py-3 sm:px-5 sm:py-5">
{parent && <Button variant="ghost" size="sm" className="mb-3" onClick={previous}><ArrowLeft className="size-4"/> Retour</Button>}
<div className="space-y-1.5">{items.length ? items.map(item=><div key={item.id} className="group flex items-center gap-3 rounded-xl border border-transparent p-3 transition-colors hover:border-border hover:bg-muted/45">
<div className="grid size-11 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">{item.is_folder?<Folder className="size-6"/>:typeOf(item)==='image'?<Image className="size-5"/>:typeOf(item)==='pdf'?<FileText className="size-5"/>:<File className="size-5"/>}</div>
<button type="button" onClick={()=>item.is_folder?setParent(item.id):setSelected(item)} className="min-w-0 flex-1 text-left"><span className="block truncate text-sm font-medium">{item.name}</span><span className="text-xs text-muted-foreground">{item.is_folder?'Dossier':fmtSize(item.size)}</span></button>
<Button variant="ghost" size="icon-sm" title={item.is_folder?'Ouvrir':'Visualiser'} onClick={()=>item.is_folder?setParent(item.id):setSelected(item)}>{item.is_folder?<FolderOpen className="size-4"/>:<Eye className="size-4"/>}</Button>
{!item.is_folder && <a className="grid size-8 shrink-0 place-items-center rounded-lg hover:bg-muted" aria-label={`Télécharger ${item.name}`} href={publicFileUrl(token,item.id)}><Download className="size-4"/></a>}
</div>):<p className="py-12 text-center text-sm text-muted-foreground">Ce dossier est vide.</p>}</div>
</div>}
{!data.root.is_folder && <div className="flex justify-end px-5 py-3"><a className="inline-flex h-10 items-center gap-2 rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground hover:opacity-90" href={publicFileUrl(token,data.root.id)}><Download className="size-4"/> Télécharger</a></div>}
</>}
</main>
</div>
{selected && <div className="fixed inset-0 z-50 flex flex-col bg-black/85 p-3 text-white sm:p-6" role="dialog" aria-modal="true" aria-label={`Visualiser ${selected.name}`}>
<div className="mb-4 flex items-center justify-between gap-2"><p className="min-w-0 truncate font-medium">{selected.name}</p><div className="flex gap-2"><a className="inline-flex items-center gap-2 rounded-xl bg-white/15 px-3 py-2 text-sm hover:bg-white/25" href={publicFileUrl(token,selected.id)}><Download className="size-4"/> Télécharger</a><Button variant="secondary" size="icon" aria-label="Fermer l’aperçu" onClick={()=>setSelected(null)}><X className="size-5"/></Button></div></div>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto"><Viewer item={selected} token={token}/></div>
</div>}
</div>
}