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) display_name = models.CharField(max_length=120)
can_view_parent = models.BooleanField(default=True) can_view_parent = models.BooleanField(default=True)
can_write_parent = models.BooleanField(default=False) can_write_parent = models.BooleanField(default=False)
parent_permissions = models.JSONField(null=True, blank=True, default=None)
can_invite = models.BooleanField(default=False) can_invite = models.BooleanField(default=False)
quota_bytes = models.BigIntegerField(null=True, blank=True, default=None) quota_bytes = models.BigIntegerField(null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True) 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') space = models.ForeignKey(DriveSpace, on_delete=models.CASCADE, related_name='access_grants')
can_write = models.BooleanField(default=False) can_write = models.BooleanField(default=False)
can_invite = 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) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@ -48,6 +50,7 @@ class SpaceInvitation(models.Model):
can_write_parent = models.BooleanField(default=False) can_write_parent = models.BooleanField(default=False)
can_invite = models.BooleanField(default=False) can_invite = models.BooleanField(default=False)
quota_bytes = models.BigIntegerField(null=True, blank=True, default=None) quota_bytes = models.BigIntegerField(null=True, blank=True, default=None)
permissions = models.JSONField(null=True, blank=True, default=None)
expires_at = models.DateTimeField() 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_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) 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') 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') 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='+') 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) created_at = models.DateTimeField(auto_now_add=True)
class Meta: 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) 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): def available_for_growth(space):
"""Bytes that can be added directly to space without breaking any ancestor allocation. """Unreserved quotas: every upload consumes capacity in its space and ALL ancestors."""
limits = []
Quotas of normal assigned children can absorb growth already reserved in for node in ancestry(space):
their ancestors. For private children, the 50-Go cap is NOT reserved: every if node.quota_bytes is not None:
newly uploaded byte immediately reduces the parent's available budget. used, pending = occupancy(node)
""" limits.append(node.quota_bytes - used - pending)
maximum=MAX_QUOTA_BYTES return max(0, min(limits)) if limits else None
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))
def assert_new_child_quota(parent, quota): def assert_new_child_quota(parent, quota):
"""A new nested space cannot reserve more than its parent's unallocated capacity.""" """A child limit may not exceed its parent's maximum, but reserves nothing."""
if quota is None:return if quota is None and parent.quota_bytes is not None:
remaining=available_for_growth(parent) raise ValueError('Le quota du sous-espace doit respecter la limite de son parent.')
if remaining is not None and quota>remaining: if quota is not None:
raise ValueError(f'Le quota dépasse les {remaining} octets disponibles dans l’espace parent.') 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): def assert_updated_quota(space, quota):
"""Validate nested allocations, including sibling limits and actual disk use. """Validate actual occupancy, nested maximums, never preallocate capacity."""
used, pending = occupancy(space)
The old reservation is replaced atomically with the requested value. Parent if quota is not None and quota < used + pending:
quotas must be large enough for their direct content and all child quotas. raise ValueError('Le quota ne peut pas être inférieur au stockage utilisé.')
""" if space.parent_id:
chain=ancestry(space) assert_new_child_quota(space.parent, quota)
committed_tree=_budget_tree(chain[-1]) if quota is not None:
committed=committed_tree[space.pk] # Keep the hierarchy valid if the parent limit shrinks.
if quota is not None and quota<committed: descendants = subtree_space_ids(space) - {space.pk}
raise ValueError('Le quota est inférieur aux fichiers et sous-espaces déjà attribués.') existing = DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=False)
current=space.quota_bytes if existing.filter(quota_bytes__gt=quota).exists():
if current==quota:return raise ValueError('Le quota est inférieur à la limite d’un sous-espace existant.')
# Compute the changed reservation at every ancestor: each parent's if quota == 0 and DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=True).exists():
# committed usage has this subtree's reserved/actual allocation substituted. raise ValueError('Un sous-espace sans limite empêche ce changement.')
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)
def quota_state(space): def quota_state(space):
used, reserved = occupancy(space) used, reserved = occupancy(space)
trashed = trash_occupancy(space) trashed = trash_occupancy(space)
available = available_for_growth(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 { return {
'quota_bytes': space.quota_bytes, '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), 'active_bytes': max(0, used - trashed),
'trashed_bytes': trashed, 'trashed_bytes': trashed,
'reserved_bytes': reserved, 'reserved_bytes': reserved,
'available_bytes': available, '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 rest_framework.response import Response
from .models import DriveItem, ItemGrant from .models import DriveItem, ItemGrant
from .spaces import can_read_space, can_write_space from .spaces import can_read_space, can_write_space
from .permissions import has_space_right, item_rights
def error(message, code=400): def error(message, code=400):
@ -22,6 +23,7 @@ def item_info(item, user=None):
} }
if user is not None: if user is not None:
data['can_write'] = writable(item,user) data['can_write'] = writable(item,user)
data['permissions'] = sorted(item_rights(user, item))
return data return data
@ -45,18 +47,13 @@ def chain(item):
def readable(item, user, allow_trashed=False): def readable(item, user, allow_trashed=False):
space_allowed = can_read_space(user,item.space) if 'view' not in item_rights(user, item):
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:
return False return False
if not allow_trashed and any(n.trashed_at is not None for n in chain(item)): return allow_trashed or not any(n.trashed_at is not None for n in chain(item))
return False
return True
def writable(item,user): def writable(item, user, action='modify'):
return can_write_space(user,item.space) return action in item_rights(user, item)
def valid_name(name): def valid_name(name):
@ -77,7 +74,7 @@ def target_parent(parent_id, user, space):
raise ValueError('Dossier de destination introuvable.') raise ValueError('Dossier de destination introuvable.')
if parent.space_id != space.id: if parent.space_id != space.id:
raise PermissionError('Impossible de déplacer un élément entre deux espaces.') 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.') raise PermissionError('Dossier de destination inaccessible en écriture.')
return parent return parent
@ -95,3 +92,20 @@ def descendant_files(node):
key=node.storage_key key=node.storage_key
files.append(node.storage_location or f'{key[:2]}/{key}') files.append(node.storage_location or f'{key[:2]}/{key}')
return files 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 django.http import Http404
from .models import DriveSpace, SpaceGrant, DriveItem from .models import DriveSpace, SpaceGrant, DriveItem
from .quotas import PRIVATE_SPACE_QUOTA_BYTES from .quotas import PRIVATE_SPACE_QUOTA_BYTES
from .permissions import has_space_right, space_rights
def is_admin(user): def is_admin(user):
@ -120,53 +121,16 @@ def _grant(user, space):
def can_read_space(user, space): def can_read_space(user, space):
if space is None: return has_space_right(user, space, 'view')
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
def can_write_space(user, space): def can_write_space(user, space):
if space is None: # Kept for legacy callers: use explicit right checks on each operation.
return False return has_space_right(user, space, 'add') or has_space_right(user, space, 'modify')
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)
def can_manage_space(user, space): def can_manage_space(user, space):
if space is None: return has_space_right(user, space, 'manage_access')
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)
def resolve_space(user, space_id=None, write=False): 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): def space_info(space, user):
from .quotas import quota_state 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 { return {
'id': space.pk, 'user_id': owner.pk, 'username': owner.username, 'id': space.pk, 'user_id': owner.pk, 'username': owner.username,
'first_name': owner.first_name, 'last_name': owner.last_name, '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), 'is_private': bool(space.private_owner_id),
'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space), 'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space),
'can_invite': can_manage_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))), 'permissions': sorted(space_rights(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)), '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, 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True,
**quota_state(space), **quota_state(space),
'can_view_parent': space.can_view_parent, 'can_write_parent': space.can_write_parent, '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(), '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('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/',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>/',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/',views_shares.public_links),
path('items/<uuid:item_id>/public-links/<int:link_id>/',views_shares.revoke_public_link), path('items/<uuid:item_id>/public-links/<int:link_id>/',views_shares.revoke_public_link),
path('spaces/create/',views_spaces.create_shared_space), 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 rest_framework.response import Response
from .services import error, get_item, readable from .services import error, get_item, readable
from .spaces import storage_path from .spaces import storage_path
from .permissions import item_rights
IMAGE_TYPES = {'image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp'} 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'} 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): def content_url(request,item_id):
item=get_item(item_id) item=get_item(item_id)
if item.is_folder or not readable(item,request.user):return error('Fichier inaccessible.',403) 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)) 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) 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. # 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): except (BadSignature,SignatureExpired,ValueError,TypeError,get_user_model().DoesNotExist):
raise Http404 raise Http404
item=get_item(item_id) 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) media=safe_preview_type(item)
if inline=='1' and not media:raise Http404 if inline=='1' and not media:raise Http404
path=storage_path(item) path=storage_path(item)

View File

@ -9,8 +9,9 @@ from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
from .models import DriveItem, DriveSpace, ItemGrant 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 .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 .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 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: for n in ancestors:
trail.append({'id':str(n.id),'name':n.name}) trail.append({'id':str(n.id),'name':n.name})
return Response({'items':items,'breadcrumb':trail,'limited':len(items)>=500, 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']) @api_view(['GET'])
@ -100,9 +102,8 @@ def stats(request):
@api_view(['POST']) @api_view(['POST'])
def create_folder(request): def create_folder(request):
try: try:
space=resolve_space(request.user,request.data.get('space'),write=True)
name=valid_name(request.data.get('name')) 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 ValueError as ex:return error(str(ex))
except PermissionError as ex:return error(str(ex),403) except PermissionError as ex:return error(str(ex),403)
with transaction.atomic(): with transaction.atomic():
@ -126,7 +127,7 @@ def create_folder(request):
@api_view(['PATCH']) @api_view(['PATCH'])
def update_item(request,item_id): def update_item(request,item_id):
item=get_item(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) return error('Modification interdite.',403)
data=request.data data=request.data
try: try:
@ -153,7 +154,7 @@ def update_item(request,item_id):
@api_view(['POST']) @api_view(['POST'])
def trash_item(request,item_id): def trash_item(request,item_id):
item=get_item(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.') 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']) item.trashed_at=timezone.now();item.save(update_fields=['trashed_at','updated_at'])
return Response({'ok':True}) return Response({'ok':True})
@ -162,7 +163,7 @@ def trash_item(request,item_id):
@api_view(['POST']) @api_view(['POST'])
def restore_item(request,item_id): def restore_item(request,item_id):
item=get_item(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 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)): if item.parent and any(n.trashed_at for n in chain(item.parent)):
try: try:
@ -176,7 +177,7 @@ def restore_item(request,item_id):
@api_view(['DELETE']) @api_view(['DELETE'])
def delete_item(request,item_id): def delete_item(request,item_id):
item=get_item(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.') if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.')
files=descendant_files(item) files=descendant_files(item)
folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None 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 import secrets
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError 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.db.models import Q
from django.utils import timezone from django.utils import timezone
from rest_framework.decorators import api_view, permission_classes, throttle_classes from rest_framework.decorators import api_view, permission_classes, throttle_classes
@ -17,15 +17,17 @@ from rest_framework.response import Response
from .models import DriveItem, ItemGrant, PublicItemLink from .models import DriveItem, ItemGrant, PublicItemLink
from .services import error, get_item, readable, writable, chain, item_info from .services import error, get_item, readable, writable, chain, item_info
from .spaces import storage_path 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): 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): def share_info(grant):
u=grant.user 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} '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) try:account=get_user_model().objects.get(pk=user_id,is_active=True)
except get_user_model().DoesNotExist:return error('Utilisateur introuvable.',404) 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à.') 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) if not created:return error('Cet élément est déjà partagé avec cet utilisateur.',409)
return Response(share_info(grant),status=201) 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']) @api_view(['DELETE'])
def remove_item_share(request,item_id,grant_id): def remove_item_share(request,item_id,grant_id):
item=get_item(item_id) item=get_item(item_id)
@ -75,7 +92,7 @@ def remove_item_share(request,item_id,grant_id):
@api_view(['POST']) @api_view(['POST'])
def public_links(request,item_id): def public_links(request,item_id):
item=get_item(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) return error('Partage non autorisé.',403)
token=secrets.token_urlsafe(32) token=secrets.token_urlsafe(32)
PublicItemLink.objects.create(item=item,token_hash=hashlib.sha256(token.encode()).hexdigest(),created_by=request.user) 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 else:folder=root
if not folder.is_folder or not in_public_tree(root,folder): if not folder.is_folder or not in_public_tree(root,folder):
if not parent_id and not root.is_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 raise Http404
qs=DriveItem.objects.filter(parent=folder,trashed_at__isnull=True).order_by('-is_folder','name')[:501] 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. # Never list ancestors outside the shared root.
breadcrumb=[] breadcrumb=[]
for node in reversed(chain(folder)): for node in reversed(chain(folder)):
@ -135,14 +152,49 @@ def public_share(request,token):
@permission_classes([AllowAny]) @permission_classes([AllowAny])
@throttle_classes([AnonRateThrottle]) @throttle_classes([AnonRateThrottle])
def public_file(request,token,item_id): 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) root=get_public_root(token)
item=get_item(item_id) item=get_item(item_id)
if item.is_folder or not in_public_tree(root,item):raise Http404 if item.is_folder or not in_public_tree(root,item):raise Http404
path=storage_path(item) path=storage_path(item)
if not path.is_file():raise Http404 if not path.is_file():raise Http404
response=FileResponse(path.open('rb'),as_attachment=True,filename=item.name, inline=request.query_params.get('inline')=='1'
content_type='application/octet-stream') 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['X-Content-Type-Options']='nosniff'
response['Cache-Control']='no-store' response['Cache-Control']='no-store'
response['Referrer-Policy']='no-referrer' 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 return response

View File

@ -19,6 +19,7 @@ from .models import (DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadS
ItemGrant, PublicItemLink, ArchivedDriveAccount) ItemGrant, PublicItemLink, ArchivedDriveAccount)
from .services import error, valid_name 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 .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, from .quotas import (parse_quota, locked_quota_tree,
assert_new_child_quota, assert_updated_quota) 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): 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), 'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at),
'revoked':bool(inv.revoked_at), 'revoked':bool(inv.revoked_at),
'can_view_parent':inv.can_view_parent,'can_write_parent':inv.can_write_parent, '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) try:child=DriveSpace.objects.select_related('parent','user').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) 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 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=[] 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 key in request.data:
if not isinstance(request.data[key],bool):return error('Les permissions doivent être des booléens.') 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) 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) except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if space.private_owner_id: if space.private_owner_id:
return error('Le quota des espaces privés est fixé à 50 Go.',403) 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) return error('Modification du quota interdite.',403)
if 'quota_bytes' not in request.data:return error('quota_bytes est obligatoire.') if 'quota_bytes' not in request.data:return error('quota_bytes est obligatoire.')
try:_update_quota(space,request.data['quota_bytes']) 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 'can_view_parent' not in request.data:fields['can_view_parent']=True
if not fields['can_view_parent']: if not fields['can_view_parent']:
return error('L’invitation donne nécessairement accès en lecture à cet espace.') 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) except ValueError as exc:return error(str(exc),403)
try:days=int(request.data.get('expires_days',7)) try:days=int(request.data.get('expires_days',7))
except (ValueError,TypeError):return error('Durée invalide.') 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). # not on invitation links (the private space always has its 50-Go cap).
token=secrets.token_urlsafe(32) token=secrets.token_urlsafe(32)
inv=SpaceInvitation.objects.create(parent_space=parent,created_by=request.user, 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. # Returned exactly once; DB stores only SHA-256.
return Response({**invitation_info(inv),'token':token},status=201) 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_view_parent':inv.can_view_parent,
'can_write_parent':inv.can_write_parent, 'can_write_parent':inv.can_write_parent,
'can_invite':inv.can_invite, '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()}) 'expires_at':inv.expires_at.isoformat()})
@ -249,11 +264,11 @@ def accept_invitation(request,token):
with transaction.atomic(): with transaction.atomic():
inv=get_valid_invitation(token,lock=True) inv=get_valid_invitation(token,lock=True)
if inv is None:return error('Invitation invalide ou déjà utilisée.',404) if inv is None:return error('Invitation invalide ou déjà utilisée.',404)
enforce_delegation(inv.created_by,inv.parent_space,view=True, rights=inv.permissions if inv.permissions is not None else legacy_rights(write=inv.can_write_parent,invite=inv.can_invite)
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) 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, 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 space=inv.parent_space
ensure_private_space(user) ensure_private_space(user)
inv.used_by=user;inv.used_at=timezone.now() inv.used_by=user;inv.used_at=timezone.now()
@ -268,7 +283,7 @@ def accept_invitation(request,token):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def grant_info(grant): def grant_info(grant):
user = grant.user 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, 'first_name': user.first_name, 'last_name': user.last_name,
'can_write': grant.can_write, 'can_invite': grant.can_invite} '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) can_invite=request.data.get('can_invite', False)
if not isinstance(can_write, bool) or not isinstance(can_invite, bool): if not isinstance(can_write, bool) or not isinstance(can_invite, bool):
return error('Permissions invalides.') 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) except ValueError as exc:return error(str(exc),403)
with transaction.atomic(): with transaction.atomic():
grant,created = SpaceGrant.objects.select_for_update().get_or_create( 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: if not created:
return error('Cet utilisateur a déjà accès à cet espace.') return error('Cet utilisateur a déjà accès à cet espace.')
return Response(grant_info(grant),status=201) 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): if not isinstance(request.data[key],bool):
return error('Permission invalide.') return error('Permission invalide.')
setattr(grant,key,request.data[key]) 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) 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)) return Response(grant_info(grant))
@ -362,9 +384,10 @@ def create_shared_space(request):
quota=parse_quota(request.data.get('quota_bytes')) quota=parse_quota(request.data.get('quota_bytes'))
parent_id=request.data.get('parent_space') parent_id=request.data.get('parent_space')
parent=DriveSpace.objects.get(pk=int(parent_id)) if parent_id else ensure_space(request.user) 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 (ValueError, TypeError) as exc:return error(str(exc))
except DriveSpace.DoesNotExist:return error('Espace parent introuvable.',404) 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 transaction.atomic():
with locked_quota_tree(parent): with locked_quota_tree(parent):
try:assert_new_child_quota(parent,quota) try:assert_new_child_quota(parent,quota)
@ -377,7 +400,7 @@ def create_shared_space(request):
def edit_shared_space(request,space_id): def edit_shared_space(request,space_id):
try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id) try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) 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) return error('Modification interdite.',403)
if request.method == 'DELETE': if request.method == 'DELETE':
return delete_shared_space(request, space_id) return delete_shared_space(request, space_id)
@ -474,7 +497,7 @@ def delete_shared_space(request, space_id):
if root is None: if root is None:
return error('Espace racine introuvable.', 409) return error('Espace racine introuvable.', 409)
admin = is_admin(request.user) 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) return error('Suppression interdite.', 403)
if confirm_name != space.display_name: if confirm_name != space.display_name:
return error('Le nom de confirmation ne correspond pas au nom de l’espace.') 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.parsers import MultiPartParser, FormParser
from rest_framework.response import Response from rest_framework.response import Response
from .models import DriveItem, UploadSession 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 .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 .storage_layout import file_location, disk_path, assert_available, prepare_parent, NameConflict
from .quotas import QuotaExceeded, locked_quota_tree, assert_space_capacity 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]) @parser_classes([MultiPartParser,FormParser])
def upload(request): def upload(request):
try: try:
space=resolve_space(request.user,request.data.get('space'),write=True) space,parent=writable_destination(request.user,request.data.get('space'),request.data.get('parent'))
parent=target_parent(request.data.get('parent'),request.user,space)
except ValueError as ex: return error(str(ex)) except ValueError as ex: return error(str(ex))
except PermissionError as ex: return error(str(ex),403) except PermissionError as ex: return error(str(ex),403)
incoming=request.FILES.getlist('files') incoming=request.FILES.getlist('files')
@ -69,8 +69,7 @@ def upload_start(request):
try: try:
name=valid_name(request.data.get('name')) name=valid_name(request.data.get('name'))
size=int(request.data.get('size',-1)) size=int(request.data.get('size',-1))
space=resolve_space(request.user,request.data.get('space'),write=True) space,parent=writable_destination(request.user,request.data.get('space'),request.data.get('parent'))
parent=target_parent(request.data.get('parent'),request.user,space)
except (TypeError,ValueError) as ex:return error(str(ex)) except (TypeError,ValueError) as ex:return error(str(ex))
except PermissionError as ex:return error(str(ex),403) 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) 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) src.touch(exist_ok=True)
if not src.exists() or src.stat().st_size!=job.total_size: if not src.exists() or src.stat().st_size!=job.total_size:
return error('Le fichier temporaire est incomplet.') 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) return error('Dossier de destination indisponible.',403)
key=str(uuid.uuid4()) key=str(uuid.uuid4())
location=file_location(job.space,job.parent,job.name) 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), 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), 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), 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}/`), 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), 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}/`), 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), 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), 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), 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), grantPatch:(id,data)=>api.patch(`/drive/spaces/users/${id}/`,data).then(r=>r.data),
grantRemove:id=>api.delete(`/drive/spaces/users/${id}/`), 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), 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 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) => { export const fileOptions = (item, trash) => {
if (!item) return [] 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: 'restore', title: 'Restaurer', icon: RotateCcw },
{ id: 'confirmdelete', title: 'Supprimer définitivement', icon: Trash2, danger: true }, { id: 'confirmdelete', title: 'Supprimer définitivement', icon: Trash2, danger: true },
] : [] ] : []
return [ return [
{ id: 'open', title: 'Ouvrir', icon: Eye }, { id: 'open', title: 'Ouvrir', icon: Eye },
...(!item.is_folder ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []), ...(!item.is_folder && rights.has('download') ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []),
...(item.can_write ? [{ id: 'share', title: 'Partager', icon: Share2 }] : []), ...(rights.has('share') ? [{ id: 'share', title: 'Partager', icon: Share2 }] : []),
...(item.can_write ? [ ...(rights.has('modify') ? [
{ id: 'rename', title: 'Renommer', icon: Pencil }, { id: 'rename', title: 'Renommer', icon: Pencil },
{ id: 'move', title: 'Déplacer', icon: FolderInput }, { 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_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_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}] : []), ...(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 { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import ContextActions, {spaceOptions, mobileDots} from './ContextActions' 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 { Database, FolderPlus, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
import { sections } from '@/config/navigation' import { sections } from '@/config/navigation'
import { fmtSize } from '@/lib/drive-format' import { fmtSize } from '@/lib/drive-format'
@ -13,7 +13,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
const [spaceMenu,setSpaceMenu] = useState(null) const [spaceMenu,setSpaceMenu] = useState(null)
const closeSpaceMenu=useCallback(()=>setSpaceMenu(null),[]) const closeSpaceMenu=useCallback(()=>setSpaceMenu(null),[])
useEffect(()=>{ 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]) },[spaces,spaceMenu,closeSpaceMenu])
const showSpaceMenu=(space,x,y)=>{ const showSpaceMenu=(space,x,y)=>{
const options=spaceOptions(space) 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 orphaned = pendingUploads.filter(job => !currentSessions.has(job.id))
const personal = spaces.find(space => space.is_private && space.user_id === user.id) const personal = spaces.find(space => space.is_private && space.user_id === user.id)
const shared = spaces.filter(space => !space.is_private) const shared = spaces.filter(space => !space.is_private)
const manageable=shared.filter(space=>space.can_invite) const manageable=shared.filter(space=>(space.permissions || []).includes('create_spaces'))
const createSpace=()=>{setSpaceDialog({mode:'create',parentId:activeSpace?.can_invite&&!activeSpace.is_private ? activeSpaceId : manageable[0]?.id});setSidebar(false)} const createSpace=()=>{setSpaceDialog({mode:'create',parentId:activeSpace?.permissions?.includes('create_spaces')&&!activeSpace.is_private ? activeSpaceId : manageable[0]?.id});setSidebar(false)}
const newFolder = () => { const newFolder = () => {
setValue('') 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> <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>} {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>
<div className="space-y-1 pb-4"> <div className="space-y-0.5 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'}`} {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}/>) }
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> </div>
<p className="mb-3 px-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground"> <p className="mb-3 px-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Explorer Explorer
@ -175,3 +165,20 @@ export default function DriveSidebar({ controller, user, onLogout }) {
</aside> </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 }) { export default function ItemActionsDialog({ controller: c }) {
const { modal, setModal, action, handleAction, isTrash } = c const { modal, setModal, action, handleAction, isTrash } = c
const rights = new Set(action?.permissions || [])
return ( return (
<Dialog open={modal === 'actions'} onOpenChange={open => { if (!open) setModal(null) }}> <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"> <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"> <div className="grid min-w-0 grid-cols-1 gap-2 min-[400px]:grid-cols-2">
{action && <> {action && <>
{!isTrash && <ActionButton icon={Eye} onClick={() => handleAction('open')}>Ouvrir</ActionButton>} {!isTrash && <ActionButton icon={Eye} onClick={() => handleAction('open')}>Ouvrir</ActionButton>}
{!action.is_folder && <ActionButton icon={Download} onClick={() => handleAction('download')}>Télécharger</ActionButton>} {!action.is_folder && rights.has('download') && <ActionButton icon={Download} onClick={() => handleAction('download')}>Télécharger</ActionButton>}
{action.can_write && !isTrash && <> {!isTrash && rights.has('share') && <ActionButton icon={Share2} onClick={() => handleAction('share')}>Partager</ActionButton>}
<ActionButton icon={Share2} onClick={() => handleAction('share')}>Partager</ActionButton> {!isTrash && rights.has('modify') && <>
<ActionButton icon={Pencil} onClick={() => handleAction('rename')}>Renommer</ActionButton> <ActionButton icon={Pencil} onClick={() => handleAction('rename')}>Renommer</ActionButton>
<ActionButton icon={FolderInput} onClick={() => handleAction('move')}>Déplacer</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={RotateCcw} onClick={() => handleAction('restore')}>Restaurer</ActionButton>
<ActionButton icon={Trash2} destructive onClick={() => handleAction('confirmdelete')}>Supprimer</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}) { export default function ItemFormDialog({controller: c}) {
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
return ( 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"> <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')}/>} {preview && <Preview item={preview} url={previewUrl} onError={()=>setPreviewUrl('unsupported')}/>}
</div> </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> </DialogContent>
</Dialog> </Dialog>
} }

View File

@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { drive, message } from '@/api/client' import { drive, message } from '@/api/client'
import PermissionsEditor, {PRESETS} from '@/components/drive/PermissionsEditor'
export default function ShareItemDialog({ controller }) { export default function ShareItemDialog({ controller }) {
const { modal, setModal, action } = controller const { modal, setModal, action } = controller
@ -15,6 +16,7 @@ export default function ShareItemDialog({ controller }) {
const [link, setLink] = useState('') const [link, setLink] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [rights,setRights]=useState(PRESETS.reader)
const reload = async (id = itemId) => { const reload = async (id = itemId) => {
if (!id) return if (!id) return
const data = await drive.itemShares(id) const data = await drive.itemShares(id)
@ -40,7 +42,7 @@ export default function ShareItemDialog({ controller }) {
finally{setBusy(false)} finally{setBusy(false)}
} }
const addUser = user => perform(async()=>{ const addUser = user => perform(async()=>{
await drive.shareItem(itemId,user.id) await drive.shareItem(itemId,user.id,rights)
setQuery('');setResults([]) setQuery('');setResults([])
}) })
const createLink = () => perform(async()=>{ const createLink = () => perform(async()=>{
@ -57,6 +59,7 @@ export default function ShareItemDialog({ controller }) {
<div className="space-y-5"> <div className="space-y-5">
<section className="space-y-3"> <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> <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)}/> <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.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)} {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>)} onClick={()=>addUser(user)}>{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} · @{user.username}</button>)}
</div>} </div>}
{record.users.map(user=><div key={user.id} className="flex items-center justify-between gap-2 rounded-lg border p-2 text-sm"> {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> <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>)} </div>)}
</section> </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> <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> <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>} {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> <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> <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>} </div>)}</div>}
</section> </section>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>} {error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div> </div>
</DialogContent> </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 {spaceDialog, setSpaceDialog, spaces, activeSpaceId, loadSpaces, switchSpace, setError, setNote, setManagedSpaceId} = c
const manage = spaceDialog?.mode === 'manage' const manage = spaceDialog?.mode === 'manage'
const current = spaces.find(space => space.id === spaceDialog?.spaceId) 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 [name,setName] = useState('')
const [quota,setQuota] = useState('') const [quota,setQuota] = useState('')
const [parent,setParent] = useState('') const [parent,setParent] = useState('')
@ -19,7 +19,7 @@ export default function SpaceSettingsDialog({controller: c}) {
const [localError,setLocalError] = useState('') const [localError,setLocalError] = useState('')
useEffect(() => { useEffect(() => {
setName(manage ? current?.display_name || '' : '') 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 || '')) setParent(String(manage ? current?.parent_id || '' : spaceDialog?.parentId || managed[0]?.id || ''))
setLocalError('') setLocalError('')
}, [spaceDialog?.mode,spaceDialog?.spaceId,spaceDialog?.parentId,current?.display_name,current?.quota_bytes,current?.parent_id,managed[0]?.id]) }, [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> </DialogHeader>
<form onSubmit={save} className="space-y-4"> <form onSubmit={save} className="space-y-4">
{!manage && <div className="space-y-2"><Label htmlFor="space-parent">Espace parent</Label> {!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"> className="h-10 w-full rounded-md border bg-background px-3 text-sm">
{managed.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)} {managed.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)}
</select></div>} </select></div>}

View File

@ -13,6 +13,7 @@ export function useDriveController(user) {
const [parent, setParent] = useState(null) const [parent, setParent] = useState(null)
const [trail, setTrail] = useState([]) const [trail, setTrail] = useState([])
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [sharedFolderAccess,setSharedFolderAccess]=useState({can_write:false,space_id:null,parentId:null})
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
const [pendingUploads, setPendingUploads] = useState([]) const [pendingUploads, setPendingUploads] = useState([])
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@ -53,7 +54,7 @@ export function useDriveController(user) {
}, [query]) }, [query])
const activeSpace = spaces.find(s => s.id === activeSpaceId) 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 () => { const loadSpaces = useCallback(async () => {
try { try {
const data = await drive.spaces() const data = await drive.spaces()
@ -89,6 +90,7 @@ export function useDriveController(user) {
const data = await drive.list(params) const data = await drive.list(params)
if (sequence !== requestSequence.current) return if (sequence !== requestSequence.current) return
setItems(data.items) setItems(data.items)
if(view==='shared')setSharedFolderAccess({can_write:data.can_write,space_id:data.space_id,parentId:parent})
setTrail(data.breadcrumb || []) setTrail(data.breadcrumb || [])
if (data.limited && !silent) setNote('La liste affiche les 500 premiers résultats. Utilise la recherche pour affiner.') if (data.limited && !silent) setNote('La liste affiche les 500 premiers résultats. Utilise la recherche pour affiner.')
if (!silent) setError('') if (!silent) setError('')
@ -191,7 +193,7 @@ export function useDriveController(user) {
const uploadFiles = incoming => { const uploadFiles = incoming => {
if (!canWrite || view === 'trash') return 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 = '' if (filesInput.current) filesInput.current.value = ''
} }
@ -275,7 +277,7 @@ export function useDriveController(user) {
const actionSubmit = event => { const actionSubmit = event => {
event.preventDefault() 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 === 'rename') return run(() => drive.patch(action.id, { name: value }))
if (modal === 'move') return run(() => drive.patch(action.id, { parent: moveFolder })) 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 { drive, message } from '@/api/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import QuotaField from '@/components/drive/QuotaField' import QuotaField from '@/components/drive/QuotaField'
import {fromQuota, toQuota} from '@/lib/quota-utils' import {fromQuota, toQuota} from '@/lib/quota-utils'
import {fmtSize} from '@/lib/drive-format' import {fmtSize} from '@/lib/drive-format'
import PermissionsEditor, {PRESETS} from '@/components/drive/PermissionsEditor'
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>
}
export default function MembersPage({ controller, user, embedded = false }) { export default function MembersPage({ controller, user, embedded = false }) {
const { spaces, activeSpaceId, loadSpaces, managedSpaceId, setManagedSpaceId, setSpaceDialog } = controller const { spaces, activeSpaceId, loadSpaces, managedSpaceId, setManagedSpaceId, setSpaceDialog } = controller
const [parentId, setParentId] = useState(managedSpaceId || user.space_id) const [parentId, setParentId] = useState(managedSpaceId || user.space_id)
const [record, setRecord] = useState(null) const [record, setRecord] = useState(null)
const reloadSequence = useRef(0) 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 [url, setUrl] = useState('')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -36,7 +25,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
const [userResults, setUserResults] = useState([]) const [userResults, setUserResults] = useState([])
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [selectedAccount, setSelectedAccount] = useState(null) 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 mine = spaces.find(s => s.id === user.space_id)
const managed = spaces.filter(s => !s.is_private && s.can_invite && (!embedded || s.id === managedSpaceId)) 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 () => { const create = async () => {
setBusy(true);setError('');setUrl('') setBusy(true);setError('');setUrl('')
try { 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}`) setUrl(`${window.location.origin}/invite/${inv.token}`)
setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.') setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.')
await reload() await reload()
@ -114,7 +103,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
if (!selectedAccount) return if (!selectedAccount) return
setBusy(true); setError('') setBusy(true); setError('')
try { 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()) setInfo(`Accès accordé à ${selectedAccount.first_name} ${selectedAccount.last_name}`.trim())
setSelectedAccount(null);setUserQuery('');setUserResults([]) setSelectedAccount(null);setUserQuery('');setUserResults([])
await reload();await loadSpaces() await reload();await loadSpaces()
@ -179,17 +168,16 @@ export default function MembersPage({ controller, user, embedded = false }) {
</div>} </div>}
{selectedAccount && <div className="space-y-3 rounded-xl border p-3"> {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> <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> <PermissionsEditor value={directRights} onChange={setDirectRights} maxRights={selectedManagedSpace?.permissions}/>
<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>
<Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button> <Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button>
</div>} </div>}
{!!directGrants.length && <div className="space-y-2 border-t pt-3"><h3 className="text-sm font-semibold">Utilisateurs ajoutés directement</h3> {!!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> </div>
<div className="rounded-2xl border bg-card p-5 space-y-4"> <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> <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 <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> <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> </label>
@ -209,9 +197,9 @@ export default function MembersPage({ controller, user, embedded = false }) {
} }
function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) { 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)) 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"> 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="min-w-0">
<div className="text-sm font-semibold">{child.display_name}</div> <div className="text-sm font-semibold">{child.display_name}</div>
@ -219,7 +207,7 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) {
</div> </div>
{child.can_invite && <Button type="button" size="sm" variant="outline" disabled={busy} onClick={onManage}>Gérer ce sous-espace</Button>} {child.can_invite && <Button type="button" size="sm" variant="outline" disabled={busy} onClick={onManage}>Gérer ce sous-espace</Button>}
</div> </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}/> <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> <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> </div></div>
@ -227,16 +215,12 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) {
function DirectMember({grant,save,remove,busy,maxRights}) { function DirectMember({grant,save,remove,busy,maxRights}) {
const [canWrite,setCanWrite] = useState(grant.can_write) const [permissions,setPermissions]=useState(grant.permissions || PRESETS.reader)
const [canInvite,setCanInvite] = useState(grant.can_invite) useEffect(()=>setPermissions(grant.permissions || PRESETS.reader),[grant])
useEffect(()=>{setCanWrite(grant.can_write);setCanInvite(grant.can_invite)},[grant]) return <div className="space-y-3 rounded-xl border p-3">
return <div className="flex flex-wrap items-center justify-between gap-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>
<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> <Button size="icon-sm" variant="ghost" disabled={busy} onClick={remove} aria-label="Retirer l’accès"><Trash2 className="size-4"/></Button></div>
<div className="flex flex-wrap items-center gap-3"> <PermissionsEditor value={permissions} maxRights={maxRights} onChange={setPermissions}/>
<label className="flex items-center gap-2 text-xs"><Checkbox disabled={!maxRights?.can_write} checked={canWrite} onCheckedChange={v=>setCanWrite(!!v)}/> Écriture</label> <Button size="sm" variant="outline" disabled={busy} onClick={()=>save({permissions})}>Enregistrer les droits</Button>
<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>
</div> </div>
} }

View File

@ -1,41 +1,72 @@
import { useCallback, useEffect, useState } from 'react' 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 { Button } from '@/components/ui/button'
import { drive, message, publicFileUrl } from '@/api/client' 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}) { export default function PublicSharePage({token}) {
const [parent,setParent]=useState(null) const [parent,setParent]=useState(null)
const [data,setData]=useState(null) const [data,setData]=useState(null)
const [selected,setSelected]=useState(null)
const [error,setError]=useState('') const [error,setError]=useState('')
const [loading,setLoading]=useState(true) const [loading,setLoading]=useState(true)
const load=useCallback(async()=>{ const load=useCallback(async()=>{
setLoading(true) setLoading(true)
try { setData(await drive.publicBrowse(token,parent));setError('') } 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)} finally{setLoading(false)}
},[token,parent]) },[token,parent])
useEffect(()=>{load()},[load]) useEffect(()=>{load()},[load])
const folderBack = () => { useEffect(()=>{if(data?.root && !data.root.is_folder)setSelected(data.root)},[data?.root?.id])
if(!data?.breadcrumb?.length || data.breadcrumb.length<=1) {setParent(null);return} const previous=()=>setParent(data?.breadcrumb?.length>1?data.breadcrumb.at(-2).id:null)
setParent(data.breadcrumb.at(-2).id) const items=data?.items || []
} return <div className="min-h-dvh bg-muted/25 px-4 py-5 sm:py-12">
return <div className="min-h-dvh bg-background px-4 py-10"> <div className="mx-auto max-w-5xl space-y-5">
<main className="mx-auto max-w-3xl rounded-2xl border bg-card p-5 shadow-sm sm:p-8"> <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>
<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> <main className="overflow-hidden rounded-3xl border bg-card shadow-sm">
{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 && <> {loading ? <div className="flex min-h-72 items-center justify-center gap-3"><LoaderCircle className="size-5 animate-spin"/> Chargement…</div>
<h2 className="mb-5 break-all text-lg font-semibold">{data.root.name}</h2> : error ? <div className="p-10 text-center text-destructive" role="alert">{error}</div>
{data.root.is_folder && <> : data && <>
<div className="mb-3 flex items-center gap-2"> <div className="border-b bg-muted/20 px-5 py-6 sm:px-8">
{!!parent && <Button variant="outline" size="sm" onClick={folderBack}><ArrowLeft className="size-4"/> Retour</Button>} <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>
<span className="truncate text-sm text-muted-foreground">{data.breadcrumb?.map(node=>node.name).join(' / ')}</span> <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>
<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"> {data.root.is_folder && <div className="px-3 py-3 sm:px-5 sm:py-5">
<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> {parent && <Button variant="ghost" size="sm" className="mb-3" onClick={previous}><ArrowLeft className="size-4"/> Retour</Button>}
{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 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>) : <p className="p-4 text-sm text-muted-foreground">Ce dossier est vide.</p>}</div> <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>
{!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>} <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> </main>
</div> </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>
} }