test
This commit is contained in:
parent
607c793b40
commit
c11c661897
@ -2,8 +2,10 @@ from django.contrib import admin
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
from drive.views import me
|
from drive.views import me
|
||||||
from drive import views_spaces
|
from drive import views_spaces, views_shares
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('api/public/<str:token>/',views_shares.public_share),
|
||||||
|
path('api/public/<str:token>/files/<uuid:item_id>/',views_shares.public_file),
|
||||||
path('api/invitations/<str:token>/',views_spaces.invitation_preview),
|
path('api/invitations/<str:token>/',views_spaces.invitation_preview),
|
||||||
path('api/invitations/<str:token>/accept/',views_spaces.accept_invitation),
|
path('api/invitations/<str:token>/accept/',views_spaces.accept_invitation),
|
||||||
path('django-admin/', admin.site.urls),
|
path('django-admin/', admin.site.urls),
|
||||||
|
|||||||
23
backend/drive/migrations/0015_item_shares.py
Normal file
23
backend/drive/migrations/0015_item_shares.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [('drive','0014_private_quota_within_assigned'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(name='ItemGrant', fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_shares', to='drive.driveitem')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shared_drive_items', to=settings.AUTH_USER_MODEL)),
|
||||||
|
], options={'constraints':[models.UniqueConstraint(fields=('item','user'), name='drive_item_user_share_unique')]}),
|
||||||
|
migrations.CreateModel(name='PublicItemLink', fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('token_hash', models.CharField(max_length=64, unique=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='public_links', to='drive.driveitem')),
|
||||||
|
]),
|
||||||
|
]
|
||||||
@ -102,3 +102,23 @@ class UploadSession(models.Model):
|
|||||||
received = models.BigIntegerField(default=0)
|
received = models.BigIntegerField(default=0)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemGrant(models.Model):
|
||||||
|
"""Read-only item/folder access, independent of membership in its space."""
|
||||||
|
item = models.ForeignKey(DriveItem, on_delete=models.CASCADE, related_name='user_shares')
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='shared_drive_items')
|
||||||
|
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [models.UniqueConstraint(fields=['item', 'user'], name='drive_item_user_share_unique')]
|
||||||
|
|
||||||
|
|
||||||
|
class PublicItemLink(models.Model):
|
||||||
|
"""Secret-token link to a single file or directory tree."""
|
||||||
|
item = models.ForeignKey(DriveItem, on_delete=models.CASCADE, related_name='public_links')
|
||||||
|
token_hash = models.CharField(max_length=64, unique=True)
|
||||||
|
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import uuid
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import DriveItem
|
from .models import DriveItem, ItemGrant
|
||||||
from .spaces import can_read_space, can_write_space
|
from .spaces import can_read_space, can_write_space
|
||||||
|
|
||||||
|
|
||||||
@ -16,12 +16,12 @@ def item_info(item, user=None):
|
|||||||
'id':str(item.id), 'name':item.name, 'is_folder':item.is_folder,
|
'id':str(item.id), 'name':item.name, 'is_folder':item.is_folder,
|
||||||
'size':item.size,'mime_type':item.mime_type,'owner_id':item.owner_id,
|
'size':item.size,'mime_type':item.mime_type,'owner_id':item.owner_id,
|
||||||
'owner':item.owner.username,'parent':str(item.parent_id) if item.parent_id else None,
|
'owner':item.owner.username,'parent':str(item.parent_id) if item.parent_id else None,
|
||||||
'space_id':item.space_id, 'is_shared':False,
|
'space_id':item.space_id, 'is_shared':item.user_shares.exists() or any(link.revoked_at is None for link in item.public_links.all()),
|
||||||
'trashed_at':item.trashed_at.isoformat() if item.trashed_at else None,
|
'trashed_at':item.trashed_at.isoformat() if item.trashed_at else None,
|
||||||
'created_at':item.created_at.isoformat(),'updated_at':item.updated_at.isoformat(),
|
'created_at':item.created_at.isoformat(),'updated_at':item.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
if user is not None:
|
if user is not None:
|
||||||
data['can_write'] = can_write_space(user, item.space)
|
data['can_write'] = writable(item,user)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@ -45,7 +45,10 @@ def chain(item):
|
|||||||
|
|
||||||
|
|
||||||
def readable(item, user, allow_trashed=False):
|
def readable(item, user, allow_trashed=False):
|
||||||
if not can_read_space(user,item.space):
|
space_allowed = can_read_space(user,item.space)
|
||||||
|
if not space_allowed and getattr(user,'is_authenticated',False) and user.is_active:
|
||||||
|
space_allowed = ItemGrant.objects.filter(user=user,item_id__in=[node.pk for node in chain(item)]).exists()
|
||||||
|
if not space_allowed:
|
||||||
return False
|
return False
|
||||||
if not allow_trashed and any(n.trashed_at is not None for n in chain(item)):
|
if not allow_trashed and any(n.trashed_at is not None for n in chain(item)):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -14,11 +14,16 @@ def is_admin(user):
|
|||||||
|
|
||||||
|
|
||||||
def ensure_space(user):
|
def ensure_space(user):
|
||||||
"""One shared/assigned space for every user; the administrator owns root."""
|
"""Return an owned space, or the existing space granted to an invitee."""
|
||||||
obj = DriveSpace.objects.filter(user=user, private_owner__isnull=True).first()
|
obj = DriveSpace.objects.filter(user=user, private_owner__isnull=True).first()
|
||||||
if obj:
|
if obj:
|
||||||
(Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True)
|
(Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
return obj
|
return obj
|
||||||
|
# Invitation-only users access an existing space through a SpaceGrant.
|
||||||
|
# They must never create another numbered shared directory implicitly.
|
||||||
|
grant = SpaceGrant.objects.filter(user=user).select_related('space').order_by('created_at','pk').first()
|
||||||
|
if grant:
|
||||||
|
return grant.space
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
obj = DriveSpace.objects.select_for_update().filter(user=user, private_owner__isnull=True).first()
|
obj = DriveSpace.objects.select_for_update().filter(user=user, private_owner__isnull=True).first()
|
||||||
if obj:
|
if obj:
|
||||||
@ -39,8 +44,9 @@ def _make_numbered_space(parent, **fields):
|
|||||||
private = fields.get('private_owner') is not None
|
private = fields.get('private_owner') is not None
|
||||||
if parent is None or parent.private_owner_id:
|
if parent is None or parent.private_owner_id:
|
||||||
raise ValueError('Un espace doit avoir un parent partagé.')
|
raise ValueError('Un espace doit avoir un parent partagé.')
|
||||||
if private and parent.user_id != fields['private_owner'].pk:
|
if private and parent.user_id != fields['private_owner'].pk and not SpaceGrant.objects.filter(
|
||||||
raise ValueError('L’espace privé doit être rattaché au quota attribué à son propriétaire.')
|
user=fields['private_owner'], space=parent).exists():
|
||||||
|
raise ValueError('L’espace privé doit être rattaché à un espace accessible par son propriétaire.')
|
||||||
base = 'privates' if private else parent.relative_path
|
base = 'privates' if private else parent.relative_path
|
||||||
if len(base) + 1 + 24 > 700:
|
if len(base) + 1 + 24 > 700:
|
||||||
raise ValueError('Hiérarchie trop profonde.')
|
raise ValueError('Hiérarchie trop profonde.')
|
||||||
@ -122,9 +128,9 @@ def can_read_space(user, space):
|
|||||||
if is_admin(user) or space.managed_by_id == user.pk:
|
if is_admin(user) or space.managed_by_id == user.pk:
|
||||||
return True
|
return True
|
||||||
own = ensure_space(user)
|
own = ensure_space(user)
|
||||||
if own.pk == space.pk:
|
if own.pk == space.pk and own.user_id == user.pk:
|
||||||
return True
|
return True
|
||||||
if own.parent_id == space.pk and own.can_view_parent:
|
if own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent:
|
||||||
return True
|
return True
|
||||||
return _grant(user, space) is not None
|
return _grant(user, space) is not None
|
||||||
|
|
||||||
@ -138,9 +144,9 @@ def can_write_space(user, space):
|
|||||||
if is_admin(user) or space.managed_by_id == user.pk:
|
if is_admin(user) or space.managed_by_id == user.pk:
|
||||||
return True
|
return True
|
||||||
own = ensure_space(user)
|
own = ensure_space(user)
|
||||||
if own.pk == space.pk:
|
if own.pk == space.pk and own.user_id == user.pk:
|
||||||
return True
|
return True
|
||||||
if own.parent_id == space.pk and own.can_view_parent and own.can_write_parent:
|
if own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent and own.can_write_parent:
|
||||||
return True
|
return True
|
||||||
grant = _grant(user, space)
|
grant = _grant(user, space)
|
||||||
return bool(grant and grant.can_write)
|
return bool(grant and grant.can_write)
|
||||||
@ -180,7 +186,9 @@ def space_info(space, user):
|
|||||||
'parent_id': space.parent_id, 'display_name': space.display_name,
|
'parent_id': space.parent_id, 'display_name': space.display_name,
|
||||||
'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), 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True,
|
'can_invite': can_manage_space(user, space),
|
||||||
|
'can_edit': is_admin(user) or space.managed_by_id == user.pk or (space.user_id == user.pk and can_manage_space(user, space)),
|
||||||
|
'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,
|
||||||
'created_at': space.created_at.isoformat(),
|
'created_at': space.created_at.isoformat(),
|
||||||
|
|||||||
119
backend/drive/test_item_sharing.py
Normal file
119
backend/drive/test_item_sharing.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""New invitation and file-sharing semantics.
|
||||||
|
|
||||||
|
Run: USE_SQLITE=true python manage.py test drive.test_item_sharing
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from .models import DriveItem, DriveSpace, SpaceGrant
|
||||||
|
from .spaces import ensure_space, ensure_private_space
|
||||||
|
|
||||||
|
|
||||||
|
class SharesTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
tmp=tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
self.storage=Path(tmp.name)
|
||||||
|
overrides=override_settings(DRIVE_STORAGE_ROOT=self.storage,
|
||||||
|
FILE_UPLOAD_TEMP_DIR=str(self.storage/'.uploads'))
|
||||||
|
overrides.enable()
|
||||||
|
self.addCleanup(overrides.disable)
|
||||||
|
User=get_user_model()
|
||||||
|
self.admin=User.objects.create_superuser('admin-shares','admin@example.org','ComplexPassword123!')
|
||||||
|
self.root=ensure_space(self.admin)
|
||||||
|
ensure_private_space(self.admin)
|
||||||
|
self.client=APIClient()
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
|
def invite_user(self, name, **rights):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
created=self.client.post('/api/drive/invitations/',{'parent_space':self.root.pk,**rights},format='json')
|
||||||
|
self.assertEqual(created.status_code,201,created.data)
|
||||||
|
client=APIClient()
|
||||||
|
accepted=client.post(f"/api/invitations/{created.data['token']}/accept/",
|
||||||
|
{'username':name,'password':'ComplexPassword123!','first_name':'Test','last_name':name},format='json')
|
||||||
|
self.assertEqual(accepted.status_code,201,accepted.data)
|
||||||
|
return get_user_model().objects.get(username=name)
|
||||||
|
|
||||||
|
def test_new_invitee_uses_existing_space_without_new_shared_directory(self):
|
||||||
|
alice=self.invite_user('alice-share',can_write_parent=False,can_invite=True)
|
||||||
|
self.assertFalse(DriveSpace.objects.filter(user=alice).exists())
|
||||||
|
self.assertTrue(SpaceGrant.objects.filter(user=alice,space=self.root,can_invite=True,can_write=False).exists())
|
||||||
|
self.assertEqual(ensure_space(alice).pk,self.root.pk)
|
||||||
|
self.assertEqual(ensure_private_space(alice).parent_id,self.root.pk)
|
||||||
|
self.client.force_authenticate(user=alice)
|
||||||
|
self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'not-writable'},format='json').status_code,403)
|
||||||
|
too_much=self.client.post('/api/drive/invitations/',{
|
||||||
|
'parent_space':self.root.pk,'can_view_parent':True,'can_write_parent':True},format='json')
|
||||||
|
self.assertEqual(too_much.status_code,403)
|
||||||
|
allowed=self.client.post('/api/drive/invitations/',{
|
||||||
|
'parent_space':self.root.pk,'can_view_parent':True,'can_invite':True},format='json')
|
||||||
|
self.assertEqual(allowed.status_code,201,allowed.data)
|
||||||
|
# Creating a subspace is an explicit operation for users with can_invite.
|
||||||
|
sub=self.client.post('/api/drive/spaces/create/',{
|
||||||
|
'parent_space':self.root.pk,'display_name':'Equipe','quota_bytes':None},format='json')
|
||||||
|
self.assertEqual(sub.status_code,201,sub.data)
|
||||||
|
|
||||||
|
def test_private_item_share_is_read_only_and_revocable(self):
|
||||||
|
alice=self.invite_user('alice-files')
|
||||||
|
bob=self.invite_user('bob-files')
|
||||||
|
private=ensure_private_space(alice)
|
||||||
|
folder=DriveItem.objects.create(owner=alice,space=private,name='Documents',is_folder=True)
|
||||||
|
child=DriveItem.objects.create(owner=alice,space=private,parent=folder,name='photo.jpg',
|
||||||
|
storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/Documents/photo.jpg',size=3)
|
||||||
|
secret=DriveItem.objects.create(owner=alice,space=private,name='secret.jpg',
|
||||||
|
storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/secret.jpg',size=3)
|
||||||
|
self.client.force_authenticate(user=alice)
|
||||||
|
shared=self.client.post(f'/api/drive/items/{folder.pk}/shares/',{'user_id':bob.pk},format='json')
|
||||||
|
self.assertEqual(shared.status_code,201,shared.data)
|
||||||
|
self.client.force_authenticate(user=bob)
|
||||||
|
listing=self.client.get('/api/drive/items/',{'view':'shared'})
|
||||||
|
self.assertEqual(listing.status_code,200,listing.data)
|
||||||
|
self.assertEqual([item['name'] for item in listing.data['items']],['Documents'])
|
||||||
|
children=self.client.get('/api/drive/items/',{'view':'shared','parent':str(folder.pk)})
|
||||||
|
self.assertEqual(children.status_code,200,children.data)
|
||||||
|
self.assertEqual(children.data['items'][0]['id'],str(child.pk))
|
||||||
|
self.assertFalse(children.data['items'][0]['can_write'])
|
||||||
|
self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/link/').status_code,200)
|
||||||
|
self.assertEqual(self.client.post(f'/api/drive/items/{secret.pk}/link/').status_code,403)
|
||||||
|
self.assertEqual(self.client.patch(f'/api/drive/items/{child.pk}/',{'name':'change.jpg'},format='json').status_code,403)
|
||||||
|
self.client.force_authenticate(user=alice)
|
||||||
|
self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/shares/{shared.data["id"]}/').status_code,204)
|
||||||
|
self.client.force_authenticate(user=bob)
|
||||||
|
self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/link/').status_code,403)
|
||||||
|
|
||||||
|
def test_public_link_access_and_revocation(self):
|
||||||
|
alice=self.invite_user('alice-public')
|
||||||
|
private=ensure_private_space(alice)
|
||||||
|
folder=DriveItem.objects.create(owner=alice,space=private,name='Photos',is_folder=True)
|
||||||
|
image=DriveItem.objects.create(owner=alice,space=private,parent=folder,name='pic.png',
|
||||||
|
storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/Photos/pic.png',size=3)
|
||||||
|
path=self.storage/image.storage_location
|
||||||
|
path.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
path.write_bytes(b'PNG')
|
||||||
|
self.client.force_authenticate(user=alice)
|
||||||
|
public=self.client.post(f'/api/drive/items/{folder.pk}/public-links/',{},format='json')
|
||||||
|
self.assertEqual(public.status_code,201,public.data)
|
||||||
|
token=public.data['token']
|
||||||
|
anonymous=APIClient()
|
||||||
|
res=anonymous.get(f'/api/public/{token}/')
|
||||||
|
self.assertEqual(res.status_code,200,res.data)
|
||||||
|
self.assertEqual(res.data['items'][0]['name'],'pic.png')
|
||||||
|
content=anonymous.get(f'/api/public/{token}/files/{image.pk}/')
|
||||||
|
self.assertEqual(content.status_code,200)
|
||||||
|
self.assertEqual(b''.join(content.streaming_content),b'PNG')
|
||||||
|
links=self.client.get(f'/api/drive/items/{folder.pk}/shares/').data['links']
|
||||||
|
self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/public-links/{links[0]["id"]}/').status_code,204)
|
||||||
|
self.assertEqual(anonymous.get(f'/api/public/{token}/').status_code,404)
|
||||||
|
|
||||||
|
def test_admin_user_list_is_private_to_admin(self):
|
||||||
|
alice=self.invite_user('alice-admin-list')
|
||||||
|
self.client.force_authenticate(user=alice)
|
||||||
|
self.assertEqual(self.client.get('/api/drive/admin/users/').status_code,403)
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
users=self.client.get('/api/drive/admin/users/',{'q':'alice-admin-list'})
|
||||||
|
self.assertEqual(users.status_code,200,users.data)
|
||||||
|
self.assertEqual(users.data['users'][0]['space_name'],self.root.display_name)
|
||||||
272
backend/drive/test_private_layout.py
Normal file
272
backend/drive/test_private_layout.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
"""Private disk layout, 50-Go private cap, and real-byte pooled accounting.
|
||||||
|
|
||||||
|
Run with: USE_SQLITE=true python manage.py test drive.test_private_layout
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from .models import DriveItem, DriveSpace, SpaceInvitation, SpaceGrant
|
||||||
|
from .quotas import (PRIVATE_SPACE_QUOTA_BYTES, QuotaExceeded, assert_space_capacity,
|
||||||
|
assert_updated_quota, locked_quota_tree, occupancy, quota_state)
|
||||||
|
from .spaces import ensure_private_space, ensure_space, create_space, storage_path
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateStorageTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.rootdir = Path(self.temp.name)
|
||||||
|
overrides = override_settings(
|
||||||
|
DRIVE_STORAGE_ROOT=self.rootdir,
|
||||||
|
FILE_UPLOAD_TEMP_DIR=str(self.rootdir / '.uploads'),
|
||||||
|
)
|
||||||
|
overrides.enable()
|
||||||
|
self.addCleanup(overrides.disable)
|
||||||
|
User = get_user_model()
|
||||||
|
self.admin = User.objects.create_superuser('root-admin', 'admin@example.org', 'TestPassword123!')
|
||||||
|
self.shared = ensure_space(self.admin)
|
||||||
|
self.private = ensure_private_space(self.admin)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
|
def test_private_directories_are_separate_from_shared_spaces(self):
|
||||||
|
self.assertEqual(self.private.parent_id, self.shared.pk)
|
||||||
|
self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}')
|
||||||
|
self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
|
||||||
|
self.assertTrue((self.rootdir / self.private.relative_path).is_dir())
|
||||||
|
person = get_user_model().objects.create_user('member', password='TestPassword123!')
|
||||||
|
shared = create_space(person, self.shared, 'Compte membre')
|
||||||
|
private = ensure_private_space(person)
|
||||||
|
self.assertTrue(shared.relative_path.startswith(f'{shared.directory_name}'))
|
||||||
|
self.assertEqual(private.relative_path, f'privates/{private.directory_name}')
|
||||||
|
self.assertEqual(private.parent_id, shared.pk)
|
||||||
|
self.assertNotEqual(shared.directory_name, private.directory_name)
|
||||||
|
|
||||||
|
def test_private_usage_counts_toward_assigned_space_without_reserving(self):
|
||||||
|
self.shared.quota_bytes = 100
|
||||||
|
self.shared.save(update_fields=['quota_bytes'])
|
||||||
|
# Private can be 50 Go even when the assigned budget is just 100 bytes.
|
||||||
|
self.assertEqual(quota_state(self.shared)['available_bytes'], 100)
|
||||||
|
self.assertEqual(quota_state(self.private)['available_bytes'], 100)
|
||||||
|
created = self.client.post('/api/drive/uploads/', {
|
||||||
|
'files': [SimpleUploadedFile('photo.jpg', b'abcdef')]
|
||||||
|
}, format='multipart')
|
||||||
|
self.assertEqual(created.status_code, 201, created.data)
|
||||||
|
node = DriveItem.objects.get(pk=created.data['items'][0]['id'])
|
||||||
|
self.assertEqual(node.storage_location, f'{self.private.relative_path}/photo.jpg')
|
||||||
|
self.assertEqual(storage_path(node).read_bytes(), b'abcdef')
|
||||||
|
self.assertEqual(occupancy(self.shared)[0], 6)
|
||||||
|
self.assertEqual(quota_state(self.shared)['available_bytes'], 94)
|
||||||
|
self.assertEqual(quota_state(self.private)['available_bytes'], 94)
|
||||||
|
self.assertEqual(quota_state(self.private)['quota_bytes'], PRIVATE_SPACE_QUOTA_BYTES)
|
||||||
|
self.assertEqual(quota_state(self.private)['used_bytes'], 6)
|
||||||
|
rejected = self.client.patch(f'/api/drive/spaces/{self.private.pk}/quota/', {
|
||||||
|
'quota_bytes': None
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(rejected.status_code, 403)
|
||||||
|
|
||||||
|
def test_actual_private_use_is_pooled_in_100_go_assigned_space(self):
|
||||||
|
person = get_user_model().objects.create_user('quota-user', password='TestPassword123!')
|
||||||
|
assigned = create_space(person, self.shared, '100 Go', quota_bytes=100 * 1024 ** 3)
|
||||||
|
private = ensure_private_space(person)
|
||||||
|
gib = 1024 ** 3
|
||||||
|
self.assertEqual(private.parent_id, assigned.pk)
|
||||||
|
self.assertEqual(private.relative_path, f'privates/{private.directory_name}')
|
||||||
|
self.assertEqual(quota_state(assigned)['available_bytes'], 100 * gib)
|
||||||
|
self.assertEqual(quota_state(private)['available_bytes'], 50 * gib)
|
||||||
|
# Existing 20 Go in shared space. This leaves both limits at their max.
|
||||||
|
DriveItem.objects.create(
|
||||||
|
owner=person, space=assigned, name='shared.bin',
|
||||||
|
storage_key=str(uuid.uuid4()), storage_location=f'{assigned.relative_path}/shared.bin',
|
||||||
|
size=20 * gib,
|
||||||
|
)
|
||||||
|
self.assertEqual(quota_state(assigned)['used_bytes'], 20 * gib)
|
||||||
|
self.assertEqual(quota_state(private)['used_bytes'], 0)
|
||||||
|
DriveItem.objects.create(
|
||||||
|
owner=person, space=private, name='private.bin',
|
||||||
|
storage_key=str(uuid.uuid4()), storage_location=f'{private.relative_path}/private.bin',
|
||||||
|
size=20 * gib,
|
||||||
|
)
|
||||||
|
self.assertEqual(occupancy(assigned)[0], 40 * gib)
|
||||||
|
self.assertEqual(quota_state(assigned)['used_bytes'], 40 * gib)
|
||||||
|
self.assertEqual(quota_state(assigned)['available_bytes'], 60 * gib)
|
||||||
|
self.assertEqual(quota_state(private)['used_bytes'], 20 * gib)
|
||||||
|
self.assertEqual(quota_state(private)['available_bytes'], 30 * gib)
|
||||||
|
with transaction.atomic():
|
||||||
|
with locked_quota_tree(assigned):
|
||||||
|
assert_space_capacity(assigned, 60 * gib)
|
||||||
|
with self.assertRaises(QuotaExceeded):
|
||||||
|
assert_space_capacity(assigned, 60 * gib + 1)
|
||||||
|
assert_updated_quota(assigned, 40 * gib)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'fichiers'):
|
||||||
|
assert_updated_quota(assigned, 39 * gib)
|
||||||
|
|
||||||
|
def test_parent_limit_blocks_private_even_before_50_go(self):
|
||||||
|
self.shared.quota_bytes = 20
|
||||||
|
self.shared.save(update_fields=['quota_bytes'])
|
||||||
|
DriveItem.objects.create(
|
||||||
|
owner=self.admin, space=self.private, name='existing.bin',
|
||||||
|
storage_key=str(uuid.uuid4()), storage_location=f'{self.private.relative_path}/existing.bin', size=19,
|
||||||
|
)
|
||||||
|
self.assertEqual(quota_state(self.private)['available_bytes'], 1)
|
||||||
|
with transaction.atomic():
|
||||||
|
with locked_quota_tree(self.private):
|
||||||
|
assert_space_capacity(self.private, 1)
|
||||||
|
with self.assertRaises(QuotaExceeded):
|
||||||
|
assert_space_capacity(self.private, 2)
|
||||||
|
|
||||||
|
def test_invitation_preview_includes_creator_full_name_and_parent_space(self):
|
||||||
|
self.admin.first_name = 'Camille'
|
||||||
|
self.admin.last_name = 'Martin'
|
||||||
|
self.admin.save(update_fields=['first_name', 'last_name'])
|
||||||
|
result = self.client.post('/api/drive/invitations/', {
|
||||||
|
'parent_space': self.shared.pk,
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(result.status_code, 201, result.data)
|
||||||
|
preview = APIClient().get(f"/api/invitations/{result.data['token']}/")
|
||||||
|
self.assertEqual(preview.status_code, 200, preview.data)
|
||||||
|
self.assertEqual(preview.data['inviter_name'], 'Camille Martin')
|
||||||
|
self.assertEqual(preview.data['parent_name'], self.shared.display_name)
|
||||||
|
|
||||||
|
self.admin.first_name = ''
|
||||||
|
self.admin.last_name = ''
|
||||||
|
self.admin.save(update_fields=['first_name', 'last_name'])
|
||||||
|
preview = APIClient().get(f"/api/invitations/{result.data['token']}/")
|
||||||
|
self.assertEqual(preview.status_code, 200, preview.data)
|
||||||
|
self.assertEqual(preview.data['inviter_name'], self.admin.username)
|
||||||
|
|
||||||
|
def test_invitation_has_no_quota_even_from_an_old_client(self):
|
||||||
|
self.shared.quota_bytes = 15
|
||||||
|
self.shared.save(update_fields=['quota_bytes'])
|
||||||
|
# Even an old client cannot allocate storage through an invitation.
|
||||||
|
result = self.client.post('/api/drive/invitations/', {
|
||||||
|
'parent_space': self.shared.pk, 'quota_bytes': 10,
|
||||||
|
}, format='json')
|
||||||
|
self.assertEqual(result.status_code, 201, result.data)
|
||||||
|
self.assertNotIn('quota_bytes', result.data)
|
||||||
|
invitation = SpaceInvitation.objects.get(pk=result.data['id'])
|
||||||
|
self.assertIsNone(invitation.quota_bytes)
|
||||||
|
self.assertEqual(quota_state(self.shared)['available_bytes'], 15)
|
||||||
|
|
||||||
|
# Legacy unredeemed invitations may still contain a saved quota.
|
||||||
|
# The quota must not be applied when the invite is accepted.
|
||||||
|
invitation.quota_bytes = 1
|
||||||
|
invitation.save(update_fields=['quota_bytes'])
|
||||||
|
preview = APIClient().get(f"/api/invitations/{result.data['token']}/")
|
||||||
|
self.assertEqual(preview.status_code, 200, preview.data)
|
||||||
|
self.assertNotIn('quota_bytes', preview.data)
|
||||||
|
accepted = APIClient().post(
|
||||||
|
f"/api/invitations/{result.data['token']}/accept/", {
|
||||||
|
'username': 'invitee', 'password': 'TestPassword123!',
|
||||||
|
'first_name': 'Jean', 'last_name': 'Dupont',
|
||||||
|
'display_name': 'Dossier invité',
|
||||||
|
}, format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(accepted.status_code, 201, accepted.data)
|
||||||
|
user = get_user_model().objects.get(username='invitee')
|
||||||
|
self.assertFalse(DriveSpace.objects.filter(user=user).exists())
|
||||||
|
self.assertTrue(SpaceGrant.objects.filter(user=user, space=self.shared).exists())
|
||||||
|
assigned = ensure_space(user)
|
||||||
|
private = ensure_private_space(user)
|
||||||
|
self.assertEqual(assigned.pk, self.shared.pk)
|
||||||
|
self.assertEqual(private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
|
||||||
|
self.assertEqual(private.parent_id, self.shared.pk)
|
||||||
|
self.assertEqual(quota_state(assigned)['available_bytes'], 15)
|
||||||
|
self.assertEqual(quota_state(private)['available_bytes'], 15)
|
||||||
|
|
||||||
|
def test_private_pending_uploads_count_in_parent(self):
|
||||||
|
from .models import UploadSession
|
||||||
|
UploadSession.objects.create(
|
||||||
|
owner=self.admin, space=self.private, name='movie.bin', total_size=1234
|
||||||
|
)
|
||||||
|
self.assertEqual(quota_state(self.shared)['reserved_bytes'], 1234)
|
||||||
|
self.assertEqual(quota_state(self.shared)['used_bytes'], 0)
|
||||||
|
self.assertEqual(quota_state(self.private)['available_bytes'], PRIVATE_SPACE_QUOTA_BYTES - 1234)
|
||||||
|
|
||||||
|
def test_private_50_go_upload_limit_is_enforced(self):
|
||||||
|
DriveItem.objects.create(
|
||||||
|
owner=self.admin, space=self.private, name='large.bin',
|
||||||
|
storage_key=str(uuid.uuid4()),
|
||||||
|
storage_location=f'{self.private.relative_path}/large.bin',
|
||||||
|
size=PRIVATE_SPACE_QUOTA_BYTES - 1,
|
||||||
|
)
|
||||||
|
with transaction.atomic():
|
||||||
|
with locked_quota_tree(self.private):
|
||||||
|
with self.assertRaises(QuotaExceeded):
|
||||||
|
assert_space_capacity(self.private, 2)
|
||||||
|
assert_space_capacity(self.private, 1)
|
||||||
|
|
||||||
|
def simulate_legacy_private(self):
|
||||||
|
"""Relocate the empty modern directory to the historical nested location."""
|
||||||
|
new = self.rootdir / self.private.relative_path
|
||||||
|
new.rmdir()
|
||||||
|
old_name = self.private.directory_name
|
||||||
|
old = f'{old_name}'
|
||||||
|
src = self.rootdir / old
|
||||||
|
(src / 'Documents').mkdir(parents=True)
|
||||||
|
(src / 'Documents' / 'facture.pdf').write_bytes(b'PDF CONTENT')
|
||||||
|
self.private.parent = self.shared
|
||||||
|
self.private.relative_path = old
|
||||||
|
self.private.quota_bytes = None
|
||||||
|
self.private.save(update_fields=['parent', 'relative_path', 'quota_bytes'])
|
||||||
|
folder = DriveItem.objects.create(
|
||||||
|
owner=self.admin, space=self.private, name='Documents', is_folder=True,
|
||||||
|
)
|
||||||
|
item = DriveItem.objects.create(
|
||||||
|
owner=self.admin, space=self.private, parent=folder, name='facture.pdf',
|
||||||
|
storage_key=str(uuid.uuid4()), storage_location=f'{old}/Documents/facture.pdf',
|
||||||
|
size=11,
|
||||||
|
)
|
||||||
|
return old, item
|
||||||
|
|
||||||
|
def test_migrate_private_storage_dry_run_and_apply(self):
|
||||||
|
old, item = self.simulate_legacy_private()
|
||||||
|
call_command('move_private_storage')
|
||||||
|
self.private.refresh_from_db()
|
||||||
|
self.assertEqual(self.private.relative_path, old)
|
||||||
|
self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists())
|
||||||
|
call_command('move_private_storage', '--apply')
|
||||||
|
self.private.refresh_from_db()
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}')
|
||||||
|
self.assertEqual(self.private.parent_id, self.shared.pk)
|
||||||
|
self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
|
||||||
|
self.assertEqual(item.storage_location, f'{self.private.relative_path}/Documents/facture.pdf')
|
||||||
|
self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT')
|
||||||
|
self.assertFalse((self.rootdir / old).exists())
|
||||||
|
self.assertFalse((self.rootdir / '.gooddrive-move-privates.json').exists())
|
||||||
|
call_command('move_private_storage', '--apply') # Idempotent.
|
||||||
|
|
||||||
|
def test_interrupted_migration_can_resume_after_directory_move(self):
|
||||||
|
from .management.commands.move_private_storage import Command, write_journal
|
||||||
|
old, item = self.simulate_legacy_private()
|
||||||
|
plan = Command().plan()
|
||||||
|
journal = self.rootdir / '.gooddrive-move-privates.json'
|
||||||
|
write_journal(journal, plan)
|
||||||
|
new = self.rootdir / 'privates' / self.private.directory_name
|
||||||
|
(self.rootdir / old).rename(new) # Simulate a crash before SQL update.
|
||||||
|
call_command('move_private_storage', '--apply')
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT')
|
||||||
|
self.assertFalse(journal.exists())
|
||||||
|
|
||||||
|
def test_migration_never_overwrites_existing_destination(self):
|
||||||
|
old, item = self.simulate_legacy_private()
|
||||||
|
target = self.rootdir / 'privates' / self.private.directory_name
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
(target / 'unrelated.txt').write_text('KEEP ME')
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command('move_private_storage', '--apply')
|
||||||
|
self.assertEqual((target / 'unrelated.txt').read_text(), 'KEEP ME')
|
||||||
|
self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists())
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.storage_location, f'{old}/Documents/facture.pdf')
|
||||||
@ -1,8 +1,14 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views
|
||||||
from . import views_spaces
|
from . import views_spaces, views_shares
|
||||||
urlpatterns=[
|
urlpatterns=[
|
||||||
path('spaces/',views_spaces.spaces),
|
path('spaces/',views_spaces.spaces),
|
||||||
|
path('admin/users/',views_spaces.admin_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/<int:grant_id>/',views_shares.remove_item_share),
|
||||||
|
path('items/<uuid:item_id>/public-links/',views_shares.public_links),
|
||||||
|
path('items/<uuid:item_id>/public-links/<int:link_id>/',views_shares.revoke_public_link),
|
||||||
path('spaces/create/',views_spaces.create_shared_space),
|
path('spaces/create/',views_spaces.create_shared_space),
|
||||||
path('spaces/<int:space_id>/',views_spaces.edit_shared_space),
|
path('spaces/<int:space_id>/',views_spaces.edit_shared_space),
|
||||||
path('spaces/me/',views_spaces.rename_my_space),
|
path('spaces/me/',views_spaces.rename_my_space),
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from django.db.models import Sum, Count
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import DriveItem, DriveSpace
|
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 .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, descendant_files
|
||||||
@ -28,7 +28,7 @@ def me(request):
|
|||||||
def listing(request):
|
def listing(request):
|
||||||
user=request.user
|
user=request.user
|
||||||
view=request.query_params.get('view','files')
|
view=request.query_params.get('view','files')
|
||||||
if view not in ('files','trash'):
|
if view not in ('files','trash','shared'):
|
||||||
return error('Vue inconnue.',400)
|
return error('Vue inconnue.',400)
|
||||||
q=request.query_params.get('q','').strip()
|
q=request.query_params.get('q','').strip()
|
||||||
parent_id=request.query_params.get('parent')
|
parent_id=request.query_params.get('parent')
|
||||||
@ -37,9 +37,20 @@ def listing(request):
|
|||||||
# In particular another person's private space is never included, even for admin.
|
# In particular another person's private space is never included, even for admin.
|
||||||
if view=='trash':
|
if view=='trash':
|
||||||
accessible=[space.pk for space in DriveSpace.objects.all() if can_read_space(user,space)]
|
accessible=[space.pk for space in DriveSpace.objects.all() if can_read_space(user,space)]
|
||||||
nodes=DriveItem.objects.filter(space_id__in=accessible,trashed_at__isnull=False).select_related('owner','parent','space')
|
nodes=DriveItem.objects.filter(space_id__in=accessible,trashed_at__isnull=False).select_related('owner','parent','space').prefetch_related('user_shares','public_links')
|
||||||
if q:nodes=nodes.filter(name__icontains=q)
|
if q:nodes=nodes.filter(name__icontains=q)
|
||||||
space=None
|
space=None
|
||||||
|
elif view=='shared':
|
||||||
|
space=None
|
||||||
|
if parent_id:
|
||||||
|
parent=get_item(parent_id)
|
||||||
|
if not parent.is_folder or not readable(parent,user):
|
||||||
|
return error('Dossier partagé inaccessible.',403)
|
||||||
|
# Without inherited item permission or normal space access, browsing is forbidden.
|
||||||
|
nodes=DriveItem.objects.filter(parent=parent,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links')
|
||||||
|
else:
|
||||||
|
nodes=DriveItem.objects.filter(user_shares__user=user,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links').distinct()
|
||||||
|
if q:nodes=nodes.filter(name__icontains=q)
|
||||||
else:
|
else:
|
||||||
try:space=resolve_space(user,request.query_params.get('space'))
|
try:space=resolve_space(user,request.query_params.get('space'))
|
||||||
except PermissionError as ex:return error(str(ex),403)
|
except PermissionError as ex:return error(str(ex),403)
|
||||||
@ -47,7 +58,7 @@ def listing(request):
|
|||||||
parent=get_item(parent_id)
|
parent=get_item(parent_id)
|
||||||
if not parent.is_folder or parent.space_id!=space.pk or not readable(parent,user):
|
if not parent.is_folder or parent.space_id!=space.pk or not readable(parent,user):
|
||||||
return error('Dossier inaccessible.',403)
|
return error('Dossier inaccessible.',403)
|
||||||
nodes=DriveItem.objects.filter(space=space,trashed_at__isnull=True).select_related('owner','parent','space')
|
nodes=DriveItem.objects.filter(space=space,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links')
|
||||||
if q:
|
if q:
|
||||||
nodes=nodes.filter(name__icontains=q)
|
nodes=nodes.filter(name__icontains=q)
|
||||||
elif parent:
|
elif parent:
|
||||||
@ -58,12 +69,18 @@ def listing(request):
|
|||||||
for n in nodes[:501]:
|
for n in nodes[:501]:
|
||||||
if readable(n,user,allow_trashed=view=='trash'):
|
if readable(n,user,allow_trashed=view=='trash'):
|
||||||
info=item_info(n,user)
|
info=item_info(n,user)
|
||||||
if view=='trash':info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers'
|
if view in ('trash','shared'):info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers'
|
||||||
items.append(info)
|
items.append(info)
|
||||||
if len(items)>=500:break
|
if len(items)>=500:break
|
||||||
trail=[]
|
trail=[]
|
||||||
if parent:
|
if parent:
|
||||||
for n in reversed(chain(parent)):
|
ancestors=list(reversed(chain(parent)))
|
||||||
|
if view=='shared' and not can_read_space(user,parent.space):
|
||||||
|
shared_ancestors=set(ItemGrant.objects.filter(user=user,item_id__in=[a.pk for a in ancestors]).values_list('item_id',flat=True))
|
||||||
|
for index,a in enumerate(ancestors):
|
||||||
|
if a.pk in shared_ancestors:
|
||||||
|
ancestors=ancestors[index:];break
|
||||||
|
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':can_write_space(user,space) if space else False,'space_id':space.pk if space else None})
|
||||||
|
|||||||
148
backend/drive/views_shares.py
Normal file
148
backend/drive/views_shares.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"""Read-only sharing of individual items and folder descendants.
|
||||||
|
|
||||||
|
User shares grant no space-level privileges; public links use cryptographically
|
||||||
|
random bearer tokens and keep only SHA-256 digests in the database.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import FileResponse, Http404
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from .models import DriveItem, ItemGrant, PublicItemLink
|
||||||
|
from .services import error, get_item, readable, writable, chain, item_info
|
||||||
|
from .spaces import storage_path
|
||||||
|
|
||||||
|
|
||||||
|
def shareable(item, user):
|
||||||
|
return readable(item, user) and writable(item, user)
|
||||||
|
|
||||||
|
|
||||||
|
def share_info(grant):
|
||||||
|
u=grant.user
|
||||||
|
return {'id': grant.pk,'user_id':u.pk,'username':u.username,
|
||||||
|
'first_name':u.first_name,'last_name':u.last_name}
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def search_share_users(request):
|
||||||
|
q=str(request.query_params.get('q','')).strip()[:100]
|
||||||
|
if len(q)<2:return Response({'users':[]})
|
||||||
|
User=get_user_model()
|
||||||
|
users=User.objects.filter(is_active=True).exclude(pk=request.user.pk).filter(
|
||||||
|
Q(username__icontains=q) |
|
||||||
|
Q(first_name__icontains=q) |
|
||||||
|
Q(last_name__icontains=q)
|
||||||
|
).order_by('username')[:15]
|
||||||
|
return Response({'users':[{'id':u.pk,'username':u.username,
|
||||||
|
'first_name':u.first_name,'last_name':u.last_name} for u in users]})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET','POST'])
|
||||||
|
def manage_item_shares(request,item_id):
|
||||||
|
item=get_item(item_id)
|
||||||
|
if not shareable(item,request.user):return error('Partage non autorisé.',403)
|
||||||
|
if request.method=='GET':
|
||||||
|
grants=ItemGrant.objects.filter(item=item).select_related('user').order_by('user__username')
|
||||||
|
links=PublicItemLink.objects.filter(item=item,revoked_at__isnull=True).order_by('-created_at')
|
||||||
|
return Response({'users':[share_info(g) for g in grants],
|
||||||
|
'links':[{'id':l.pk,'created_at':l.created_at.isoformat()} for l in links]})
|
||||||
|
if item.trashed_at:return error('Impossible de partager un élément supprimé.',400)
|
||||||
|
try:user_id=int(request.data.get('user_id'))
|
||||||
|
except (TypeError,ValueError):return error('Utilisateur invalide.')
|
||||||
|
try:account=get_user_model().objects.get(pk=user_id,is_active=True)
|
||||||
|
except get_user_model().DoesNotExist:return error('Utilisateur introuvable.',404)
|
||||||
|
if account.pk==request.user.pk:return error('Cet élément t’appartient déjà.')
|
||||||
|
grant,created=ItemGrant.objects.get_or_create(item=item,user=account,defaults={'created_by':request.user})
|
||||||
|
if not created:return error('Cet élément est déjà partagé avec cet utilisateur.',409)
|
||||||
|
return Response(share_info(grant),status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
def remove_item_share(request,item_id,grant_id):
|
||||||
|
item=get_item(item_id)
|
||||||
|
if not shareable(item,request.user):return error('Partage non autorisé.',403)
|
||||||
|
deleted,_=ItemGrant.objects.filter(pk=grant_id,item=item).delete()
|
||||||
|
if not deleted:return error('Partage introuvable.',404)
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
def public_links(request,item_id):
|
||||||
|
item=get_item(item_id)
|
||||||
|
if not shareable(item,request.user) or not readable(item,request.user):
|
||||||
|
return error('Partage non autorisé.',403)
|
||||||
|
token=secrets.token_urlsafe(32)
|
||||||
|
PublicItemLink.objects.create(item=item,token_hash=hashlib.sha256(token.encode()).hexdigest(),created_by=request.user)
|
||||||
|
return Response({'token':token},status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
def revoke_public_link(request,item_id,link_id):
|
||||||
|
item=get_item(item_id)
|
||||||
|
if not shareable(item,request.user):return error('Partage non autorisé.',403)
|
||||||
|
changed=PublicItemLink.objects.filter(pk=link_id,item=item,revoked_at__isnull=True).update(revoked_at=timezone.now())
|
||||||
|
if not changed:return error('Lien introuvable.',404)
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_root(token):
|
||||||
|
if not token or len(token)>128:raise Http404
|
||||||
|
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
try:link=PublicItemLink.objects.select_related('item','item__parent','item__space').get(
|
||||||
|
token_hash=digest,revoked_at__isnull=True)
|
||||||
|
except PublicItemLink.DoesNotExist:raise Http404
|
||||||
|
if any(n.trashed_at for n in chain(link.item)):raise Http404
|
||||||
|
return link.item
|
||||||
|
|
||||||
|
|
||||||
|
def in_public_tree(root, candidate):
|
||||||
|
if candidate.space_id!=root.space_id:return False
|
||||||
|
return any(ancestor.pk==root.pk for ancestor in chain(candidate)) and not any(n.trashed_at for n in chain(candidate))
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
@throttle_classes([AnonRateThrottle])
|
||||||
|
def public_share(request,token):
|
||||||
|
root=get_public_root(token)
|
||||||
|
parent_id=request.query_params.get('parent')
|
||||||
|
if parent_id:
|
||||||
|
try:folder=get_item(parent_id)
|
||||||
|
except (Http404,ValueError,ValidationError):raise Http404
|
||||||
|
else:folder=root
|
||||||
|
if not folder.is_folder or not in_public_tree(root,folder):
|
||||||
|
if not parent_id and not root.is_folder:
|
||||||
|
return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':False},'items':[]})
|
||||||
|
raise Http404
|
||||||
|
qs=DriveItem.objects.filter(parent=folder,trashed_at__isnull=True).order_by('-is_folder','name')[:501]
|
||||||
|
items=[{'id':str(i.pk),'name':i.name,'is_folder':i.is_folder,'size':i.size} for i in qs]
|
||||||
|
# Never list ancestors outside the shared root.
|
||||||
|
breadcrumb=[]
|
||||||
|
for node in reversed(chain(folder)):
|
||||||
|
if node.pk==root.pk or breadcrumb:
|
||||||
|
breadcrumb.append({'id':str(node.pk),'name':node.name})
|
||||||
|
return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':True},
|
||||||
|
'breadcrumb':breadcrumb,'items':items[:500],'limited':len(items)>500})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET','HEAD'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
@throttle_classes([AnonRateThrottle])
|
||||||
|
def public_file(request,token,item_id):
|
||||||
|
root=get_public_root(token)
|
||||||
|
item=get_item(item_id)
|
||||||
|
if item.is_folder or not in_public_tree(root,item):raise Http404
|
||||||
|
path=storage_path(item)
|
||||||
|
if not path.is_file():raise Http404
|
||||||
|
response=FileResponse(path.open('rb'),as_attachment=True,filename=item.name,
|
||||||
|
content_type='application/octet-stream')
|
||||||
|
response['X-Content-Type-Options']='nosniff'
|
||||||
|
response['Cache-Control']='no-store'
|
||||||
|
response['Referrer-Policy']='no-referrer'
|
||||||
|
return response
|
||||||
@ -17,7 +17,7 @@ from rest_framework.throttling import AnonRateThrottle
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession
|
from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession
|
||||||
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_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 .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)
|
||||||
|
|
||||||
@ -27,10 +27,20 @@ def permission_flags(data):
|
|||||||
|
|
||||||
|
|
||||||
def manager(user, parent):
|
def manager(user, parent):
|
||||||
# Administrators can manage any descendant; delegates their own direct children only.
|
|
||||||
return can_manage_space(user, parent)
|
return can_manage_space(user, parent)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_delegation(user, space, *, view=True, write=False, invite=False):
|
||||||
|
# These checks must be repeated on the server for each creation/change.
|
||||||
|
if view and not can_read_space(user, space):
|
||||||
|
raise ValueError('Tu ne peux pas attribuer la lecture de cet espace.')
|
||||||
|
if write and not can_write_space(user, space):
|
||||||
|
raise ValueError('Tu ne peux pas attribuer l’écriture de cet espace.')
|
||||||
|
if invite and not can_manage_space(user, space):
|
||||||
|
raise ValueError('Tu ne peux pas déléguer la gestion des utilisateurs.')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def invitation_info(inv):
|
def invitation_info(inv):
|
||||||
return {'id':str(inv.pk), 'created_at':inv.created_at.isoformat(),
|
return {'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),
|
||||||
@ -48,7 +58,7 @@ def spaces(request):
|
|||||||
permitted=[private, *qs.filter(private_owner__isnull=True)]
|
permitted=[private, *qs.filter(private_owner__isnull=True)]
|
||||||
else:
|
else:
|
||||||
permitted=[private,own]
|
permitted=[private,own]
|
||||||
if own.parent_id and own.can_view_parent and not own.parent.private_owner_id:
|
if own.user_id == request.user.pk and own.parent_id and own.can_view_parent and not own.parent.private_owner_id:
|
||||||
permitted.append(own.parent)
|
permitted.append(own.parent)
|
||||||
permitted.extend(space for space in qs.filter(access_grants__user=request.user) if not space.private_owner_id)
|
permitted.extend(space for space in qs.filter(access_grants__user=request.user) if not space.private_owner_id)
|
||||||
permitted.extend(qs.filter(managed_by=request.user,private_owner__isnull=True))
|
permitted.extend(qs.filter(managed_by=request.user,private_owner__isnull=True))
|
||||||
@ -60,6 +70,8 @@ def spaces(request):
|
|||||||
@api_view(['PATCH'])
|
@api_view(['PATCH'])
|
||||||
def rename_my_space(request):
|
def rename_my_space(request):
|
||||||
own=my_space(request.user)
|
own=my_space(request.user)
|
||||||
|
if own.user_id != request.user.pk and not is_admin(request.user):
|
||||||
|
return error('Impossible de renommer un espace attribué à un autre compte.',403)
|
||||||
try:
|
try:
|
||||||
title=valid_name(request.data.get('display_name'))
|
title=valid_name(request.data.get('display_name'))
|
||||||
except ValueError as ex:return error(str(ex))
|
except ValueError as ex:return error(str(ex))
|
||||||
@ -110,6 +122,9 @@ def update_member(request,space_id):
|
|||||||
setattr(child,key,request.data[key]);changes.append(key)
|
setattr(child,key,request.data[key]);changes.append(key)
|
||||||
if child.can_write_parent and not child.can_view_parent:
|
if child.can_write_parent and not child.can_view_parent:
|
||||||
return error('L’écriture nécessite la lecture de l’espace parent.')
|
return error('L’écriture nécessite la lecture de l’espace parent.')
|
||||||
|
try:enforce_delegation(request.user,child.parent,view=child.can_view_parent,
|
||||||
|
write=child.can_write_parent,invite=child.can_invite)
|
||||||
|
except ValueError as exc:return error(str(exc),403)
|
||||||
if 'quota_bytes' in request.data:
|
if 'quota_bytes' in request.data:
|
||||||
try:_update_quota(child, request.data['quota_bytes'])
|
try:_update_quota(child, request.data['quota_bytes'])
|
||||||
except ValueError as ex:return error(str(ex))
|
except ValueError as ex:return error(str(ex))
|
||||||
@ -149,8 +164,10 @@ def create_invitation(request):
|
|||||||
if not manager(request.user,parent):return error('Vous ne pouvez pas inviter depuis cet espace.',403)
|
if not manager(request.user,parent):return error('Vous ne pouvez pas inviter depuis cet espace.',403)
|
||||||
fields=permission_flags(request.data)
|
fields=permission_flags(request.data)
|
||||||
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 fields['can_write_parent'] and not fields['can_view_parent']:
|
if not fields['can_view_parent']:
|
||||||
return error('L’écriture nécessite la lecture de l’espace parent.')
|
return error('L’invitation donne nécessairement accès en lecture à cet espace.')
|
||||||
|
try:enforce_delegation(request.user,parent,view=fields['can_view_parent'],write=fields['can_write_parent'],invite=fields['can_invite'])
|
||||||
|
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.')
|
||||||
if not 1<=days<=30:return error('La durée doit être comprise entre 1 et 30 jours.')
|
if not 1<=days<=30:return error('La durée doit être comprise entre 1 et 30 jours.')
|
||||||
@ -207,14 +224,13 @@ def accept_invitation(request,token):
|
|||||||
if get_valid_invitation(token) is None:return error('Invitation invalide ou expirée.',404)
|
if get_valid_invitation(token) is None:return error('Invitation invalide ou expirée.',404)
|
||||||
username=str(request.data.get('username','')).strip()
|
username=str(request.data.get('username','')).strip()
|
||||||
password=request.data.get('password','')
|
password=request.data.get('password','')
|
||||||
display_name=str(request.data.get('display_name','')).strip()
|
|
||||||
email=str(request.data.get('email','')).strip()
|
email=str(request.data.get('email','')).strip()
|
||||||
first_name=str(request.data.get('first_name','')).strip()
|
first_name=str(request.data.get('first_name','')).strip()
|
||||||
last_name=str(request.data.get('last_name','')).strip()
|
last_name=str(request.data.get('last_name','')).strip()
|
||||||
if not first_name or not last_name or len(first_name)>150 or len(last_name)>150:
|
if not first_name or not last_name or len(first_name)>150 or len(last_name)>150:
|
||||||
return error('Prénom et nom obligatoires (150 caractères maximum).')
|
return error('Prénom et nom obligatoires (150 caractères maximum).')
|
||||||
if not username or len(username)>150 or not display_name or len(display_name)>120:
|
if not username or len(username)>150:
|
||||||
return error('Nom d’utilisateur et nom d’espace obligatoires (150/120 caractères maximum).')
|
return error('Nom d’utilisateur obligatoire (150 caractères maximum).')
|
||||||
User=get_user_model()
|
User=get_user_model()
|
||||||
try:
|
try:
|
||||||
User._meta.get_field(User.USERNAME_FIELD).run_validators(username)
|
User._meta.get_field(User.USERNAME_FIELD).run_validators(username)
|
||||||
@ -226,15 +242,17 @@ 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)
|
||||||
with locked_quota_tree(inv.parent_space):
|
enforce_delegation(inv.created_by,inv.parent_space,view=True,
|
||||||
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
|
write=inv.can_write_parent,invite=inv.can_invite)
|
||||||
space=create_space(user,inv.parent_space,display_name,
|
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
|
||||||
can_view_parent=inv.can_view_parent,can_write_parent=inv.can_write_parent,
|
SpaceGrant.objects.create(user=user,space=inv.parent_space,
|
||||||
can_invite=inv.can_invite)
|
can_write=inv.can_write_parent,can_invite=inv.can_invite)
|
||||||
|
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()
|
||||||
inv.save(update_fields=['used_by','used_at'])
|
inv.save(update_fields=['used_by','used_at'])
|
||||||
except IntegrityError:return error('Le nom d’utilisateur est déjà utilisé.')
|
except IntegrityError:return error('Le nom d’utilisateur est déjà utilisé.')
|
||||||
|
except ValueError as exc:return error(str(exc),403)
|
||||||
return Response({'ok':True,'username':user.username,'space_id':space.pk},status=201)
|
return Response({'ok':True,'username':user.username,'space_id':space.pk},status=201)
|
||||||
|
|
||||||
|
|
||||||
@ -289,13 +307,15 @@ def add_existing_user(request):
|
|||||||
return error('Utilisateur introuvable.',404)
|
return error('Utilisateur introuvable.',404)
|
||||||
if account.pk == parent.user_id or account.pk == request.user.pk:
|
if account.pk == parent.user_id or account.pk == request.user.pk:
|
||||||
return error('Impossible de vous ajouter vous-même à cet espace.')
|
return error('Impossible de vous ajouter vous-même à cet espace.')
|
||||||
own = ensure_space(account)
|
own = DriveSpace.objects.filter(user=account, private_owner__isnull=True).first()
|
||||||
if own.parent_id == parent.pk:
|
if own and own.parent_id == parent.pk:
|
||||||
return error('Cet utilisateur est déjà un sous-utilisateur direct de cet espace.')
|
return error('Cet utilisateur est déjà un sous-utilisateur direct de cet espace.')
|
||||||
can_write=request.data.get('can_write', False)
|
can_write=request.data.get('can_write', False)
|
||||||
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)
|
||||||
|
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': can_write,'can_invite':can_invite})
|
||||||
@ -320,6 +340,8 @@ 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)
|
||||||
|
except ValueError as exc:return error(str(exc),403)
|
||||||
grant.save(update_fields=['can_write','can_invite'])
|
grant.save(update_fields=['can_write','can_invite'])
|
||||||
return Response(grant_info(grant))
|
return Response(grant_info(grant))
|
||||||
|
|
||||||
@ -348,7 +370,8 @@ 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 manager(request.user,space):return error('Modification interdite.',403)
|
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))):
|
||||||
|
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)
|
||||||
try:
|
try:
|
||||||
@ -438,3 +461,24 @@ def delete_shared_space(request, space_id):
|
|||||||
transaction.on_commit(lambda: _remove_deleted_space_files(
|
transaction.on_commit(lambda: _remove_deleted_space_files(
|
||||||
space.relative_path, legacy_locations, upload_ids))
|
space.relative_path, legacy_locations, upload_ids))
|
||||||
return Response({'deleted_space_ids': ids})
|
return Response({'deleted_space_ids': ids})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
def admin_users(request):
|
||||||
|
if not is_admin(request.user):return error('Accès réservé aux administrateurs.',403)
|
||||||
|
query = str(request.query_params.get('q','')).strip()[:100]
|
||||||
|
User = get_user_model()
|
||||||
|
qs = User.objects.order_by('username')
|
||||||
|
if query:
|
||||||
|
qs = qs.filter(Q(username__icontains=query)|Q(first_name__icontains=query)|Q(last_name__icontains=query)|Q(email__icontains=query))
|
||||||
|
result=[]
|
||||||
|
for user in qs[:201]:
|
||||||
|
assigned=DriveSpace.objects.filter(user=user,private_owner__isnull=True).first()
|
||||||
|
if not assigned:
|
||||||
|
grant=SpaceGrant.objects.filter(user=user).order_by('created_at','pk').first()
|
||||||
|
assigned=grant.space if grant else None
|
||||||
|
result.append({'id':user.pk,'username':user.username,'first_name':user.first_name,
|
||||||
|
'last_name':user.last_name,'email':user.email,'is_active':user.is_active,
|
||||||
|
'is_admin':is_admin(user), 'space_name':assigned.display_name if assigned else None,
|
||||||
|
'date_joined':user.date_joined.isoformat()})
|
||||||
|
return Response({'users':result[:200], 'limited':len(result)>200})
|
||||||
|
|||||||
@ -58,6 +58,14 @@ api.interceptors.response.use(response => response, async error => {
|
|||||||
})
|
})
|
||||||
export const drive={
|
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),
|
||||||
|
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),
|
||||||
|
unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`),
|
||||||
|
publicItem:id=>api.post(`/drive/items/${id}/public-links/`).then(r=>r.data),
|
||||||
|
revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`),
|
||||||
|
publicBrowse:(token,parent)=>api.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data),
|
||||||
|
adminUsers:q=>api.get('/drive/admin/users/',{params:{q}}).then(r=>r.data),
|
||||||
stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data),
|
stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data),
|
||||||
folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data),
|
folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data),
|
||||||
patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),
|
patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),
|
||||||
@ -107,3 +115,5 @@ export const drive={
|
|||||||
inviteAccept:(token,data)=>api.post(`/invitations/${encodeURIComponent(token)}/accept/`,data).then(r=>r.data),
|
inviteAccept:(token,data)=>api.post(`/invitations/${encodeURIComponent(token)}/accept/`,data).then(r=>r.data),
|
||||||
}
|
}
|
||||||
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) => `${api.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/`
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users } from 'lucide-react'
|
import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users, Share2 } from 'lucide-react'
|
||||||
|
|
||||||
export const fileOptions = (item, trash) => {
|
export const fileOptions = (item, trash) => {
|
||||||
if (!item) return []
|
if (!item) return []
|
||||||
@ -11,6 +11,7 @@ export const fileOptions = (item, trash) => {
|
|||||||
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 ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []),
|
||||||
|
...(item.can_write ? [{ id: 'share', title: 'Partager', icon: Share2 }] : []),
|
||||||
...(item.can_write ? [
|
...(item.can_write ? [
|
||||||
{ 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 },
|
||||||
@ -20,7 +21,7 @@ export const fileOptions = (item, trash) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const spaceOptions = (space) => space?.can_invite ? [
|
export const spaceOptions = (space) => space?.can_invite ? [
|
||||||
{id:'manage',title:'Paramètres de l’espace',icon:Settings2},
|
...(space.can_edit ? [{id:'manage',title:'Paramètres de l’espace',icon:Settings2}] : []),
|
||||||
{id:'members',title:'Accès et invitations',icon:Users},
|
{id:'members',title:'Accès et invitations',icon:Users},
|
||||||
...(space.has_account || space.is_private || !space.parent_id ? [] : [{id:'delete',title:'Supprimer l’espace',icon:Trash2,danger:true}]),
|
...(space.has_account || space.is_private || !space.parent_id ? [] : [{id:'delete',title:'Supprimer l’espace',icon:Trash2,danger:true}]),
|
||||||
] : []
|
] : []
|
||||||
|
|||||||
@ -5,12 +5,14 @@ import PreviewDialog from './dialogs/PreviewDialog'
|
|||||||
import SpaceSettingsDialog from './dialogs/SpaceSettingsDialog'
|
import SpaceSettingsDialog from './dialogs/SpaceSettingsDialog'
|
||||||
import MembersDialog from './dialogs/MembersDialog'
|
import MembersDialog from './dialogs/MembersDialog'
|
||||||
import DeleteSpaceDialog from './dialogs/DeleteSpaceDialog'
|
import DeleteSpaceDialog from './dialogs/DeleteSpaceDialog'
|
||||||
|
import ShareItemDialog from './dialogs/ShareItemDialog'
|
||||||
|
|
||||||
/** Groups the Drive's existing dialogs without coupling them to the page markup. */
|
/** Groups the Drive's existing dialogs without coupling them to the page markup. */
|
||||||
export default function DriveDialogs({ controller, user }) {
|
export default function DriveDialogs({ controller, user }) {
|
||||||
return <>
|
return <>
|
||||||
<ItemFormDialog controller={controller} />
|
<ItemFormDialog controller={controller} />
|
||||||
<ItemActionsDialog controller={controller} user={user} />
|
<ItemActionsDialog controller={controller} user={user} />
|
||||||
|
<ShareItemDialog controller={controller} />
|
||||||
<DeleteDialog controller={controller} />
|
<DeleteDialog controller={controller} />
|
||||||
<PreviewDialog controller={controller} />
|
<PreviewDialog controller={controller} />
|
||||||
<SpaceSettingsDialog controller={controller} />
|
<SpaceSettingsDialog controller={controller} />
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
|
|
||||||
const title = search
|
const title = search
|
||||||
? `« ${search} »`
|
? `« ${search} »`
|
||||||
: trail.length && !search ? trail.at(-1)?.name : view === 'files' ? (activeSpace?.display_name || headerNames[view]) : headerNames[view]
|
: trail.length && !search ? trail.at(-1)?.name : view === 'files' ? (activeSpace?.display_name || headerNames[view]) : (view === 'shared' ? 'Partagés avec moi' : headerNames[view])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4 pt-6 sm:px-7 sm:pb-7">
|
<section className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4 pt-6 sm:px-7 sm:pb-7">
|
||||||
@ -45,14 +45,14 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
<div className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{isTrash
|
{isTrash
|
||||||
? <span className="px-1">Tous les espaces accessibles</span>
|
? <span className="px-1">Tous les espaces accessibles</span>
|
||||||
: <button onClick={goRoot} className="rounded px-1 py-1 hover:bg-muted">{activeSpace?.is_private ? 'Mes fichiers' : activeSpace?.display_name || 'Mes fichiers'}</button>}
|
: <button onClick={goRoot} className="rounded px-1 py-1 hover:bg-muted">{view === 'shared' ? 'Partagés avec moi' : activeSpace?.is_private ? 'Mes fichiers' : activeSpace?.display_name || 'Mes fichiers'}</button>}
|
||||||
{view === 'files' && !search && trail.map(folder => (
|
{(view === 'files' || view === 'shared') && !search && trail.map(folder => (
|
||||||
<React.Fragment key={folder.id}>
|
<React.Fragment key={folder.id}>
|
||||||
<ChevronRight className="size-3.5" />
|
<ChevronRight className="size-3.5" />
|
||||||
<button className="max-w-28 truncate rounded px-1 py-1 hover:bg-muted" onClick={() => setParent(folder.id)}>{folder.name}</button>
|
<button className="max-w-28 truncate rounded px-1 py-1 hover:bg-muted" onClick={() => setParent(folder.id)}>{folder.name}</button>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
{view !== 'files' && <>
|
{view !== 'files' && view !== 'shared' && <>
|
||||||
<ChevronRight className="size-3.5" />
|
<ChevronRight className="size-3.5" />
|
||||||
<span>{headerNames[view]}</span>
|
<span>{headerNames[view]}</span>
|
||||||
</>}
|
</>}
|
||||||
@ -96,13 +96,13 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
<FolderOpen className="size-9 text-primary" />
|
<FolderOpen className="size-9 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">
|
||||||
{isTrash ? 'La corbeille est vide' : search ? 'Aucun résultat' : 'Cet espace est vide'}
|
{isTrash ? 'La corbeille est vide' : search ? 'Aucun résultat' : view === 'shared' ? 'Aucun fichier partagé' : 'Cet espace est vide'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||||
{isTrash
|
{isTrash
|
||||||
? 'Les fichiers supprimés apparaîtront ici avant leur suppression définitive.'
|
? 'Les fichiers supprimés apparaîtront ici avant leur suppression définitive.'
|
||||||
: search ? 'Essaie un autre nom de fichier.'
|
: search ? 'Essaie un autre nom de fichier.'
|
||||||
: 'Importe tes premiers fichiers ou crée un dossier pour commencer à organiser ton espace.'}
|
: view === 'shared' ? 'Les éléments que d’autres utilisateurs partagent avec toi apparaîtront ici.' : 'Importe tes premiers fichiers ou crée un dossier pour commencer à organiser ton espace.'}
|
||||||
</p>
|
</p>
|
||||||
{!isTrash && !search && canWrite && (
|
{!isTrash && !search && canWrite && (
|
||||||
<Button className="mt-5" onClick={() => filesInput.current?.click()}>
|
<Button className="mt-5" onClick={() => filesInput.current?.click()}>
|
||||||
@ -120,7 +120,7 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
<ContextActions menu={context && {...context, options:fileOptions(context.item,isTrash)}} onClose={closeContext}
|
<ContextActions menu={context && {...context, options:fileOptions(context.item,isTrash)}} onClose={closeContext}
|
||||||
onChoose={type => performItemAction(context.item,type)} />
|
onChoose={type => performItemAction(context.item,type)} />
|
||||||
<footer className="flex justify-between gap-2 px-1 pt-3 text-[11px] text-muted-foreground">
|
<footer className="flex justify-between gap-2 px-1 pt-3 text-[11px] text-muted-foreground">
|
||||||
<span className="hidden md:inline">Clic droit sur un élément pour les actions.</span><span className="md:hidden">Tes fichiers restent sur ton serveur.</span>
|
<span className="hidden md:inline">Clic droit sur un élément pour les actions.</span>
|
||||||
<span>{!isTrash && stats ? `${stats.folders} dossiers · ${stats.files} fichiers` : ''}</span>
|
<span>{!isTrash && stats ? `${stats.folders} dossiers · ${stats.files} fichiers` : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ 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 { MoreHorizontal, Plus } from 'lucide-react'
|
||||||
import { Database, FolderPlus, LogOut, FolderLock, FolderOpen, X } 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'
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
|||||||
Explorer
|
Explorer
|
||||||
</p>
|
</p>
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{sections.map(({ id, name, icon: Icon }) => {
|
{[{id:'shared',name:'Partagés avec moi',icon:Share2},...sections,...(user.is_admin?[{id:'users',name:'Utilisateurs',icon:Users}]:[])].map(({ id, name, icon: Icon }) => {
|
||||||
const active = view === id && !search
|
const active = view === id && !search
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw } from 'lucide-react'
|
import { Eye, Download, Share2, Pencil, FolderInput, Trash2, RotateCcw } from 'lucide-react'
|
||||||
|
|
||||||
const ActionButton = ({ icon: Icon, children, destructive = false, ...props }) => (
|
const ActionButton = ({ icon: Icon, children, destructive = false, ...props }) => (
|
||||||
<Button variant="outline" className={`w-full min-w-0 justify-start gap-2 px-3 ${destructive ? 'text-destructive' : ''}`} {...props}>
|
<Button variant="outline" className={`w-full min-w-0 justify-start gap-2 px-3 ${destructive ? 'text-destructive' : ''}`} {...props}>
|
||||||
@ -22,6 +22,7 @@ export default function ItemActionsDialog({ controller: c }) {
|
|||||||
{!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 && <ActionButton icon={Download} onClick={() => handleAction('download')}>Télécharger</ActionButton>}
|
||||||
{action.can_write && !isTrash && <>
|
{action.can_write && !isTrash && <>
|
||||||
|
<ActionButton icon={Share2} onClick={() => handleAction('share')}>Partager</ActionButton>
|
||||||
<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>
|
<ActionButton icon={Trash2} destructive onClick={() => handleAction('trash')}>Corbeille</ActionButton>
|
||||||
@ -32,7 +33,7 @@ export default function ItemActionsDialog({ controller: c }) {
|
|||||||
</>}
|
</>}
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-relaxed text-muted-foreground">Les fichiers restent privés. Seuls les membres autorisés de cet espace peuvent y accéder.</p>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
84
frontend/src/components/drive/dialogs/ShareItemDialog.jsx
Normal file
84
frontend/src/components/drive/dialogs/ShareItemDialog.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Copy, Link2, LoaderCircle, Trash2, UserPlus } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { drive, message } from '@/api/client'
|
||||||
|
|
||||||
|
export default function ShareItemDialog({ controller }) {
|
||||||
|
const { modal, setModal, action } = controller
|
||||||
|
const opened = modal === 'share' && !!action
|
||||||
|
const itemId = opened ? action.id : null
|
||||||
|
const [record, setRecord] = useState({users:[], links:[]})
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [link, setLink] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const reload = async (id = itemId) => {
|
||||||
|
if (!id) return
|
||||||
|
const data = await drive.itemShares(id)
|
||||||
|
setRecord(data)
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!itemId) return
|
||||||
|
let active = true
|
||||||
|
setRecord({users:[],links:[]}); setLink(''); setQuery(''); setResults([]); setError('')
|
||||||
|
drive.itemShares(itemId).then(data => { if(active)setRecord(data) }).catch(e=>{if(active)setError(message(e))})
|
||||||
|
return () => {active=false}
|
||||||
|
}, [itemId])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!itemId || query.trim().length<2) {setResults([]);return}
|
||||||
|
let active=true
|
||||||
|
const timer=setTimeout(()=>drive.shareUsers(query).then(users=>{if(active)setResults(users)}).catch(()=>{if(active)setResults([])}),250)
|
||||||
|
return ()=>{active=false;clearTimeout(timer)}
|
||||||
|
},[query,itemId])
|
||||||
|
const perform = async task => {
|
||||||
|
setBusy(true);setError('')
|
||||||
|
try {await task();await reload()}
|
||||||
|
catch(e){setError(message(e))}
|
||||||
|
finally{setBusy(false)}
|
||||||
|
}
|
||||||
|
const addUser = user => perform(async()=>{
|
||||||
|
await drive.shareItem(itemId,user.id)
|
||||||
|
setQuery('');setResults([])
|
||||||
|
})
|
||||||
|
const createLink = () => perform(async()=>{
|
||||||
|
const result=await drive.publicItem(itemId)
|
||||||
|
setLink(`${window.location.origin}/s/${result.token}`)
|
||||||
|
})
|
||||||
|
const copy = async () => {
|
||||||
|
try {await navigator.clipboard.writeText(link)}
|
||||||
|
catch {setError('Impossible de copier le lien automatiquement.')}
|
||||||
|
}
|
||||||
|
return <Dialog open={opened} onOpenChange={v=>{if(!v)setModal(null)}}>
|
||||||
|
<DialogContent className="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-lg overflow-y-auto">
|
||||||
|
<DialogHeader><DialogTitle className="break-all">Partager « {action?.name} »</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold"><UserPlus className="size-4"/> Partager avec un utilisateur</h3>
|
||||||
|
<Input placeholder="Rechercher un utilisateur…" value={query} onChange={e=>setQuery(e.target.value)}/>
|
||||||
|
{results.length>0 && <div className="max-h-44 overflow-y-auto rounded-lg border p-1">
|
||||||
|
{results.map(user=><button key={user.id} type="button" disabled={busy || record.users.some(u=>u.user_id===user.id)}
|
||||||
|
className="block w-full rounded-md px-3 py-2 text-left text-sm hover:bg-muted disabled:opacity-50"
|
||||||
|
onClick={()=>addUser(user)}>{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} · @{user.username}</button>)}
|
||||||
|
</div>}
|
||||||
|
{record.users.map(user=><div key={user.id} className="flex items-center justify-between gap-2 rounded-lg border p-2 text-sm">
|
||||||
|
<span className="min-w-0 truncate">{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username})</span>
|
||||||
|
<Button size="icon-sm" variant="ghost" disabled={busy} title="Retirer l’accès" onClick={()=>perform(()=>drive.unshareItem(itemId,user.id))}><Trash2 className="size-4"/></Button>
|
||||||
|
</div>)}
|
||||||
|
</section>
|
||||||
|
<section className="space-y-3 border-t pt-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold"><Link2 className="size-4"/> Accès public</h3>
|
||||||
|
<Button type="button" variant="outline" disabled={busy} onClick={createLink}>{busy?<LoaderCircle className="animate-spin"/>:<Link2/>} Générer un lien public</Button>
|
||||||
|
{link && <div className="flex items-center gap-2"><Input readOnly value={link} aria-label="Lien de partage public"/><Button variant="outline" size="icon" onClick={copy} title="Copier"><Copy/></Button></div>}
|
||||||
|
{record.links.length>0 && <div className="space-y-2">{record.links.map(publicLink=><div key={publicLink.id} className="flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm">
|
||||||
|
<span>Lien créé le {new Date(publicLink.created_at).toLocaleDateString('fr-FR')}</span>
|
||||||
|
<Button size="icon-sm" variant="ghost" disabled={busy} title="Révoquer le lien" onClick={()=>perform(()=>drive.revokePublic(itemId,publicLink.id))}><Trash2 className="size-4"/></Button>
|
||||||
|
</div>)}</div>}
|
||||||
|
</section>
|
||||||
|
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
@ -1,3 +1,3 @@
|
|||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
export const sections = [{ id:'trash', name:'Corbeille', icon:Trash2 }]
|
export const sections = [{ id:'trash', name:'Corbeille', icon:Trash2 }]
|
||||||
export const headerNames = { files:'Mes fichiers', trash:'Corbeille' }
|
export const headerNames = { files:'Mes fichiers', shared:'Partagés avec moi', users:'Utilisateurs', trash:'Corbeille' }
|
||||||
|
|||||||
@ -53,7 +53,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 = activeSpace?.can_write ?? false
|
const canWrite = view === 'files' && Boolean(activeSpace?.can_write)
|
||||||
const loadSpaces = useCallback(async () => {
|
const loadSpaces = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await drive.spaces()
|
const data = await drive.spaces()
|
||||||
@ -79,11 +79,12 @@ export function useDriveController(user) {
|
|||||||
const sequence = ++requestSequence.current
|
const sequence = ++requestSequence.current
|
||||||
if (!silent) setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
if (view === 'users') { setLoading(false); return }
|
||||||
const params = { view, space: activeSpaceId }
|
const params = { view, space: activeSpaceId }
|
||||||
if (view === 'files' && parent) params.parent = parent
|
if ((view === 'files' || view === 'shared') && parent) params.parent = parent
|
||||||
if (search) {
|
if (search) {
|
||||||
params.q = search
|
params.q = search
|
||||||
if (view === 'files') delete params.parent
|
if (view === 'files' || view === 'shared') delete params.parent
|
||||||
}
|
}
|
||||||
const data = await drive.list(params)
|
const data = await drive.list(params)
|
||||||
if (sequence !== requestSequence.current) return
|
if (sequence !== requestSequence.current) return
|
||||||
@ -147,7 +148,7 @@ export function useDriveController(user) {
|
|||||||
|
|
||||||
const openFolder = item => {
|
const openFolder = item => {
|
||||||
setParent(item.id)
|
setParent(item.id)
|
||||||
setView('files')
|
setView(view === 'shared' ? 'shared' : 'files')
|
||||||
setQuery('')
|
setQuery('')
|
||||||
setSidebar(false)
|
setSidebar(false)
|
||||||
}
|
}
|
||||||
@ -252,7 +253,7 @@ export function useDriveController(user) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const goRoot = () => {
|
const goRoot = () => {
|
||||||
setView('files')
|
setView(view === 'shared' ? 'shared' : 'files')
|
||||||
setParent(null)
|
setParent(null)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}
|
}
|
||||||
|
|||||||
38
frontend/src/pages/AdminUsersPage.jsx
Normal file
38
frontend/src/pages/AdminUsersPage.jsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Users, LoaderCircle } from 'lucide-react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { drive, message } from '@/api/client'
|
||||||
|
|
||||||
|
export default function AdminUsersPage(){
|
||||||
|
const [query,setQuery] = useState('')
|
||||||
|
const [record,setRecord] = useState({users:[]})
|
||||||
|
const [busy,setBusy] = useState(true)
|
||||||
|
const [error,setError] = useState('')
|
||||||
|
useEffect(()=>{
|
||||||
|
let active=true
|
||||||
|
const timer=setTimeout(async()=>{
|
||||||
|
setBusy(true)
|
||||||
|
try {const data=await drive.adminUsers(query);if(active){setRecord(data);setError('')}}
|
||||||
|
catch(e){if(active)setError(message(e))}
|
||||||
|
finally{if(active)setBusy(false)}
|
||||||
|
},query?300:0)
|
||||||
|
return ()=>{active=false;clearTimeout(timer)}
|
||||||
|
},[query])
|
||||||
|
return <div className="min-h-0 flex-1 overflow-auto p-4 sm:p-7">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-5">
|
||||||
|
<h1 className="flex items-center gap-3 text-2xl font-semibold"><Users/> Utilisateurs</h1>
|
||||||
|
<Input placeholder="Rechercher par nom, identifiant ou e-mail…" value={query} onChange={e=>setQuery(e.target.value)}/>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
{busy?<LoaderCircle className="size-6 animate-spin"/>:<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[650px] text-left text-sm"><thead className="bg-muted/50 text-muted-foreground"><tr><th className="p-3">Utilisateur</th><th className="p-3">Identifiant</th><th className="p-3">E-mail</th><th className="p-3">Espace attribué</th><th className="p-3">Statut</th></tr></thead>
|
||||||
|
<tbody className="divide-y">{record.users.map(user=><tr key={user.id}>
|
||||||
|
<td className="p-3 font-medium">{[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'}</td>
|
||||||
|
<td className="p-3">@{user.username}</td><td className="p-3">{user.email||'—'}</td><td className="p-3">{user.space_name||'—'}</td>
|
||||||
|
<td className="p-3">{user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''}</td>
|
||||||
|
</tr>)}</tbody></table>
|
||||||
|
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
|
||||||
|
</div>}
|
||||||
|
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import DriveToolbar from '@/components/drive/DriveToolbar'
|
|||||||
import DriveExplorer from '@/components/drive/DriveExplorer'
|
import DriveExplorer from '@/components/drive/DriveExplorer'
|
||||||
import DriveDialogs from '@/components/drive/DriveDialogs'
|
import DriveDialogs from '@/components/drive/DriveDialogs'
|
||||||
import UploadTray from '@/components/drive/UploadTray'
|
import UploadTray from '@/components/drive/UploadTray'
|
||||||
|
import AdminUsersPage from '@/pages/AdminUsersPage'
|
||||||
|
|
||||||
export default function DrivePage({user, onLogout}) {
|
export default function DrivePage({user, onLogout}) {
|
||||||
const controller = useDriveController(user)
|
const controller = useDriveController(user)
|
||||||
@ -18,11 +19,13 @@ export default function DrivePage({user, onLogout}) {
|
|||||||
{sidebar && <button className="fixed inset-0 z-30 bg-black/40 md:hidden" aria-label="Fermer le menu" onClick={() => setSidebar(false)} />}
|
{sidebar && <button className="fixed inset-0 z-30 bg-black/40 md:hidden" aria-label="Fermer le menu" onClick={() => setSidebar(false)} />}
|
||||||
<DriveSidebar controller={controller} user={user} onLogout={onLogout} />
|
<DriveSidebar controller={controller} user={user} onLogout={onLogout} />
|
||||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<DriveToolbar controller={controller} />
|
{controller.view === 'users' && user.is_admin ? <AdminUsersPage /> : <>
|
||||||
<DriveExplorer controller={controller} user={user} />
|
<DriveToolbar controller={controller} />
|
||||||
|
<DriveExplorer controller={controller} user={user} />
|
||||||
|
</>}
|
||||||
</main>
|
</main>
|
||||||
<input className="hidden" ref={filesInput} type="file" multiple onChange={e => uploadFiles(e.target.files)} />
|
<input className="hidden" ref={filesInput} type="file" multiple onChange={e => uploadFiles(e.target.files)} />
|
||||||
{drag && !isTrash && canWrite && <div className="pointer-events-none fixed inset-3 z-50 grid place-items-center rounded-3xl border-4 border-dashed border-primary bg-background/90"><div className="text-center"><CloudUpload className="mx-auto mb-3 size-14 text-primary"/><h2 className="text-xl font-semibold">Dépose tes fichiers ici</h2><p className="mt-1 text-muted-foreground">Ils seront importés sur ton serveur.</p></div></div>}
|
{drag && !isTrash && canWrite && <div className="pointer-events-none fixed inset-3 z-50 grid place-items-center rounded-3xl border-4 border-dashed border-primary bg-background/90"><div className="text-center"><CloudUpload className="mx-auto mb-3 size-14 text-primary"/><h2 className="text-xl font-semibold">Dépose tes fichiers ici</h2></div></div>}
|
||||||
<DriveDialogs controller={controller} user={user} />
|
<DriveDialogs controller={controller} user={user} />
|
||||||
<UploadTray uploads={controller.uploads} />
|
<UploadTray uploads={controller.uploads} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export default function InvitationPage({ token }) {
|
|||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = useState('')
|
const [lastName, setLastName] = useState('')
|
||||||
const [displayName, setDisplayName] = useState('')
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirmation, setConfirmation] = useState('')
|
const [confirmation, setConfirmation] = useState('')
|
||||||
@ -26,7 +25,7 @@ export default function InvitationPage({ token }) {
|
|||||||
if (password !== confirmation) { setError('Les mots de passe sont différents.'); return }
|
if (password !== confirmation) { setError('Les mots de passe sont différents.'); return }
|
||||||
setBusy(true); setError('')
|
setBusy(true); setError('')
|
||||||
try {
|
try {
|
||||||
await drive.inviteAccept(token, { username, password, email, display_name: displayName, first_name: firstName, last_name: lastName })
|
await drive.inviteAccept(token, { username, password, email, first_name: firstName, last_name: lastName })
|
||||||
setDone(true)
|
setDone(true)
|
||||||
} catch (exception) { setError(message(exception)) }
|
} catch (exception) { setError(message(exception)) }
|
||||||
finally { setBusy(false) }
|
finally { setBusy(false) }
|
||||||
@ -39,12 +38,11 @@ export default function InvitationPage({ token }) {
|
|||||||
alt="Logo de GooDrive"
|
alt="Logo de GooDrive"
|
||||||
className="block size-full object-contain"
|
className="block size-full object-contain"
|
||||||
/></div><div><h1 className="text-xl font-bold">GoodDrive</h1><p className="text-xs text-muted-foreground">Un espace pour ceux qui en ont besoin.</p></div></div>
|
/></div><div><h1 className="text-xl font-bold">GoodDrive</h1><p className="text-xs text-muted-foreground">Un espace pour ceux qui en ont besoin.</p></div></div>
|
||||||
{done ? <div className="space-y-4"><h2 className="text-2xl font-semibold">Bienvenue sur GoodDrive !</h2><p>Ton espace personnel et ton espace partagé sont prêts. Tu peux maintenant te connecter.</p><Button onClick={() => window.location.assign('/')} className="w-full">Se connecter <ArrowRight /></Button></div>
|
{done ? <div className="space-y-4"><h2 className="text-2xl font-semibold">Bienvenue sur GoodDrive !</h2><p>Ton espace privé et ton accès à l’espace partagé sont prêts. Tu peux maintenant te connecter.</p><Button onClick={() => window.location.assign('/')} className="w-full">Se connecter <ArrowRight /></Button></div>
|
||||||
: info ? <><div><h2 className="text-2xl font-semibold">Créer mon espace</h2><p className="mt-2 text-sm text-muted-foreground">Tu as été invité par <span className="font-medium text-foreground">{info.inviter_name}</span>. Tu auras un espace privé de 50 Go à disposition, ainsi qu'un accès à l'espace « {info.parent_name} ».</p></div>
|
: info ? <><div><h2 className="text-2xl font-semibold">Créer mon compte</h2><p className="mt-2 text-sm text-muted-foreground">Tu as été invité par <span className="font-medium text-foreground">{info.inviter_name}</span>. Tu auras un espace privé de 50 Go à disposition, ainsi qu'un accès à l'espace « {info.parent_name} ».</p></div>
|
||||||
<form onSubmit={submit} className="space-y-3">
|
<form onSubmit={submit} className="space-y-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="block text-sm font-medium">Prénom<Input required maxLength={150} autoComplete="given-name" value={firstName} onChange={e=>setFirstName(e.target.value)}/></label><label className="block text-sm font-medium">Nom<Input required maxLength={150} autoComplete="family-name" value={lastName} onChange={e=>setLastName(e.target.value)}/></label></div>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="block text-sm font-medium">Prénom<Input required maxLength={150} autoComplete="given-name" value={firstName} onChange={e=>setFirstName(e.target.value)}/></label><label className="block text-sm font-medium">Nom<Input required maxLength={150} autoComplete="family-name" value={lastName} onChange={e=>setLastName(e.target.value)}/></label></div>
|
||||||
<label className="block text-sm font-medium">Identifiant<Input required maxLength={150} autoComplete="username" value={username} onChange={e=>setUsername(e.target.value)} /></label>
|
<label className="block text-sm font-medium">Identifiant<Input required maxLength={150} autoComplete="username" value={username} onChange={e=>setUsername(e.target.value)} /></label>
|
||||||
<label className="block text-sm font-medium">Nom affiché de mon espace<Input required maxLength={120} value={displayName} onChange={e=>setDisplayName(e.target.value)} placeholder="Mon Drive"/></label>
|
|
||||||
<label className="block text-sm font-medium">E-mail (facultatif)<Input type="email" autoComplete="email" maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label>
|
<label className="block text-sm font-medium">E-mail (facultatif)<Input type="email" autoComplete="email" maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label>
|
||||||
<label className="block text-sm font-medium">Mot de passe<Input type="password" required autoComplete="new-password" value={password} onChange={e=>setPassword(e.target.value)}/></label>
|
<label className="block text-sm font-medium">Mot de passe<Input type="password" required autoComplete="new-password" value={password} onChange={e=>setPassword(e.target.value)}/></label>
|
||||||
<label className="block text-sm font-medium">Confirmer le mot de passe<Input type="password" required autoComplete="new-password" value={confirmation} onChange={e=>setConfirmation(e.target.value)}/></label>
|
<label className="block text-sm font-medium">Confirmer le mot de passe<Input type="password" required autoComplete="new-password" value={confirmation} onChange={e=>setConfirmation(e.target.value)}/></label>
|
||||||
|
|||||||
@ -13,9 +13,9 @@ const options = [
|
|||||||
{ key: 'can_write_parent', label: 'Modifier les fichiers du parent' },
|
{ key: 'can_write_parent', label: 'Modifier les fichiers du parent' },
|
||||||
{ key: 'can_invite', label: 'Créer des sous-utilisateurs' },
|
{ key: 'can_invite', label: 'Créer des sous-utilisateurs' },
|
||||||
]
|
]
|
||||||
function Permissions({ value, onChange, disabled = false }) {
|
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">
|
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} 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}
|
<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>
|
</label>)}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
|
|||||||
{embedded && selectedManagedSpace && <div className="rounded-2xl border bg-card p-4 text-sm">
|
{embedded && selectedManagedSpace && <div className="rounded-2xl border bg-card p-4 text-sm">
|
||||||
<p className="font-semibold">Gestion de « {selectedManagedSpace.display_name} »</p>
|
<p className="font-semibold">Gestion de « {selectedManagedSpace.display_name} »</p>
|
||||||
</div>}
|
</div>}
|
||||||
{!embedded && <div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 font-semibold">Nom de mon espace</h2>
|
{!embedded && mine?.user_id === user.id && <div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 font-semibold">Nom de mon espace</h2>
|
||||||
<div className="flex flex-wrap gap-2"><Input className="max-w-sm" value={title} onChange={e=>setTitle(e.target.value)} maxLength={120}/><Button disabled={busy||!title.trim()} onClick={rename}>Enregistrer</Button></div>
|
<div className="flex flex-wrap gap-2"><Input className="max-w-sm" value={title} onChange={e=>setTitle(e.target.value)} maxLength={120}/><Button disabled={busy||!title.trim()} onClick={rename}>Enregistrer</Button></div>
|
||||||
</div>}
|
</div>}
|
||||||
{!embedded && user.is_admin && <div className="rounded-2xl border bg-card p-5 space-y-4">
|
{!embedded && user.is_admin && <div className="rounded-2xl border bg-card p-5 space-y-4">
|
||||||
@ -174,17 +174,17 @@ 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 checked={directRights.can_write} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_write:!!v}))}/> Autoriser les modifications dans cet espace</label>
|
<label className="flex items-center gap-2 text-sm"><Checkbox disabled={!selectedManagedSpace?.can_write} checked={directRights.can_write} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_write:!!v}))}/> Autoriser les modifications dans cet espace</label>
|
||||||
<label className="flex items-center gap-2 text-sm"><Checkbox checked={directRights.can_invite} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_invite:!!v}))}/> Autoriser la gestion des utilisateurs de cet espace</label>
|
<label className="flex items-center gap-2 text-sm"><Checkbox disabled={!selectedManagedSpace?.can_invite} checked={directRights.can_invite} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_invite:!!v}))}/> Autoriser la gestion des utilisateurs de cet espace</label>
|
||||||
<Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button>
|
<Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button>
|
||||||
</div>}
|
</div>}
|
||||||
{!!selectedRecord?.grants?.length && <div className="space-y-2 border-t pt-3"><h3 className="text-sm font-semibold">Utilisateurs ajoutés directement</h3>
|
{!!selectedRecord?.grants?.length && <div className="space-y-2 border-t pt-3"><h3 className="text-sm font-semibold">Utilisateurs ajoutés directement</h3>
|
||||||
{record.grants.map(g=><DirectMember key={g.id} grant={g} busy={busy} save={data=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)}
|
{record.grants.map(g=><DirectMember key={g.id} grant={g} maxRights={selectedManagedSpace} 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}/>
|
<Permissions value={rights} onChange={setRights} fixedRead maxRights={{can_view_parent:true,can_write_parent:!!selectedManagedSpace?.can_write,can_invite:!!selectedManagedSpace?.can_invite}}/>
|
||||||
<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>
|
||||||
@ -193,7 +193,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border bg-card p-5 space-y-4"><h2 className="flex items-center gap-2 font-semibold"><Users className="size-5 text-primary"/> Sous-utilisateurs et sous-espaces</h2>
|
<div className="rounded-2xl border bg-card p-5 space-y-4"><h2 className="flex items-center gap-2 font-semibold"><Users className="size-5 text-primary"/> Sous-utilisateurs et sous-espaces</h2>
|
||||||
{!selectedRecord?.children?.length && <p className="text-sm text-muted-foreground">Aucun sous-utilisateur dans cet espace.</p>}
|
{!selectedRecord?.children?.length && <p className="text-sm text-muted-foreground">Aucun sous-utilisateur dans cet espace.</p>}
|
||||||
{selectedRecord?.children?.map(child=><MemberRow key={child.id} child={child} busy={busy} save={data=>change(child.id,data)} onManage={() => manageSubspace(child)} onInvalid={setError}/>)}
|
{selectedRecord?.children?.map(child=><MemberRow key={child.id} child={child} maxRights={selectedManagedSpace} busy={busy} save={data=>change(child.id,data)} onManage={() => manageSubspace(child)} onInvalid={setError}/>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 flex items-center gap-2 font-semibold"><ShieldCheck className="size-5 text-primary"/> Invitations</h2>
|
<div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 flex items-center gap-2 font-semibold"><ShieldCheck className="size-5 text-primary"/> Invitations</h2>
|
||||||
<div className="space-y-2">{selectedRecord?.invitations?.map(inv=><div key={inv.id} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm"><span>Créée le {new Date(inv.created_at).toLocaleDateString('fr-FR')} · {inv.used?'Utilisée':inv.revoked?'Révoquée':new Date(inv.expires_at)<new Date()?'Expirée':'En attente'}</span>{!inv.used&&!inv.revoked&&<Button size="icon-sm" variant="ghost" title="Révoquer" disabled={busy} onClick={()=>revoke(inv.id)}><Trash2 className="size-4"/></Button>}</div>)}</div>
|
<div className="space-y-2">{selectedRecord?.invitations?.map(inv=><div key={inv.id} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm"><span>Créée le {new Date(inv.created_at).toLocaleDateString('fr-FR')} · {inv.used?'Utilisée':inv.revoked?'Révoquée':new Date(inv.expires_at)<new Date()?'Expirée':'En attente'}</span>{!inv.used&&!inv.revoked&&<Button size="icon-sm" variant="ghost" title="Révoquer" disabled={busy} onClick={()=>revoke(inv.id)}><Trash2 className="size-4"/></Button>}</div>)}</div>
|
||||||
@ -203,7 +203,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
function MemberRow({child,save,busy,onManage,onInvalid}) {
|
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({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite})
|
||||||
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({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])
|
||||||
@ -214,22 +214,22 @@ function MemberRow({child,save,busy,onManage,onInvalid}) {
|
|||||||
</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}/>
|
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}}/>
|
||||||
<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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function DirectMember({grant,save,remove,busy}) {
|
function DirectMember({grant,save,remove,busy,maxRights}) {
|
||||||
const [canWrite,setCanWrite] = useState(grant.can_write)
|
const [canWrite,setCanWrite] = useState(grant.can_write)
|
||||||
const [canInvite,setCanInvite] = useState(grant.can_invite)
|
const [canInvite,setCanInvite] = useState(grant.can_invite)
|
||||||
useEffect(()=>{setCanWrite(grant.can_write);setCanInvite(grant.can_invite)},[grant])
|
useEffect(()=>{setCanWrite(grant.can_write);setCanInvite(grant.can_invite)},[grant])
|
||||||
return <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3">
|
return <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3">
|
||||||
<div className="min-w-36"><div className="text-sm font-semibold">{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}</div><div className="text-xs text-muted-foreground">@{grant.username}</div></div>
|
<div className="min-w-36"><div className="text-sm font-semibold">{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}</div><div className="text-xs text-muted-foreground">@{grant.username}</div></div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<label className="flex items-center gap-2 text-xs"><Checkbox checked={canWrite} onCheckedChange={v=>setCanWrite(!!v)}/> Écriture</label>
|
<label className="flex items-center gap-2 text-xs"><Checkbox disabled={!maxRights?.can_write} checked={canWrite} onCheckedChange={v=>setCanWrite(!!v)}/> Écriture</label>
|
||||||
<label className="flex items-center gap-2 text-xs"><Checkbox checked={canInvite} onCheckedChange={v=>setCanInvite(!!v)}/> Invitations</label>
|
<label className="flex items-center gap-2 text-xs"><Checkbox disabled={!maxRights?.can_invite} checked={canInvite} onCheckedChange={v=>setCanInvite(!!v)}/> Invitations</label>
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={()=>save({can_write:canWrite,can_invite:canInvite})}>Enregistrer</Button>
|
<Button size="sm" variant="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>
|
<Button size="sm" variant="ghost" disabled={busy} onClick={remove} title="Retirer l’accès"><Trash2 className="size-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
frontend/src/pages/PublicSharePage.jsx
Normal file
41
frontend/src/pages/PublicSharePage.jsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ArrowLeft, Download, File, Folder, LoaderCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { drive, message, publicFileUrl } from '@/api/client'
|
||||||
|
|
||||||
|
export default function PublicSharePage({token}) {
|
||||||
|
const [parent,setParent] = useState(null)
|
||||||
|
const [data,setData] = useState(null)
|
||||||
|
const [error,setError] = useState('')
|
||||||
|
const [loading,setLoading] = useState(true)
|
||||||
|
const load = useCallback(async()=>{
|
||||||
|
setLoading(true)
|
||||||
|
try {setData(await drive.publicBrowse(token,parent));setError('')}
|
||||||
|
catch(e){setError(e?.response?.status===404 ? 'Ce lien est invalide ou n’est plus disponible.' : message(e))}
|
||||||
|
finally{setLoading(false)}
|
||||||
|
},[token,parent])
|
||||||
|
useEffect(()=>{load()},[load])
|
||||||
|
const folderBack = () => {
|
||||||
|
if(!data?.breadcrumb?.length || data.breadcrumb.length<=1) {setParent(null);return}
|
||||||
|
setParent(data.breadcrumb.at(-2).id)
|
||||||
|
}
|
||||||
|
return <div className="min-h-dvh bg-background px-4 py-10">
|
||||||
|
<main className="mx-auto max-w-3xl rounded-2xl border bg-card p-5 shadow-sm sm:p-8">
|
||||||
|
<div className="mb-6 flex items-center gap-3"><img src="/icon.png" alt="GoodDrive" className="size-10"/><div><h1 className="text-xl font-bold">GoodDrive</h1><p className="text-sm text-muted-foreground">Fichier partagé publiquement</p></div></div>
|
||||||
|
{loading ? <p className="flex items-center gap-2"><LoaderCircle className="size-4 animate-spin"/> Chargement…</p> : error ? <p className="text-destructive" role="alert">{error}</p> : data && <>
|
||||||
|
<h2 className="mb-5 break-all text-lg font-semibold">{data.root.name}</h2>
|
||||||
|
{data.root.is_folder && <>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
{!!parent && <Button variant="outline" size="sm" onClick={folderBack}><ArrowLeft className="size-4"/> Retour</Button>}
|
||||||
|
<span className="truncate text-sm text-muted-foreground">{data.breadcrumb?.map(node=>node.name).join(' / ')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y rounded-lg border">{data.items?.length ? data.items.map(item=><div key={item.id} className="flex items-center justify-between gap-3 p-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">{item.is_folder?<Folder className="size-5 shrink-0"/>:<File className="size-5 shrink-0"/>}<span className="truncate text-sm">{item.name}</span></div>
|
||||||
|
{item.is_folder ? <Button size="sm" variant="outline" onClick={()=>setParent(item.id)}>Ouvrir</Button> : <a className="grid size-8 place-items-center rounded-lg border hover:bg-muted" title="Télécharger" href={publicFileUrl(token,item.id)}><Download className="size-4"/></a>}
|
||||||
|
</div>) : <p className="p-4 text-sm text-muted-foreground">Ce dossier est vide.</p>}</div>
|
||||||
|
</>}
|
||||||
|
{!data.root.is_folder && <a className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground" href={publicFileUrl(token,data.root.id)}><Download className="size-4"/> Télécharger {data.root.name}</a>}
|
||||||
|
</>}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user