This commit is contained in:
Louis Mazin 2026-10-14 15:58:46 +02:00
parent 607c793b40
commit c11c661897
25 changed files with 917 additions and 76 deletions

View File

@ -2,8 +2,10 @@ from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from drive.views import me
from drive import views_spaces
from drive import views_spaces, views_shares
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>/accept/',views_spaces.accept_invitation),
path('django-admin/', admin.site.urls),

View 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')),
]),
]

View File

@ -102,3 +102,23 @@ class UploadSession(models.Model):
received = models.BigIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=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)

View File

@ -3,7 +3,7 @@ import uuid
from django.core.exceptions import ValidationError
from django.http import Http404
from rest_framework.response import Response
from .models import DriveItem
from .models import DriveItem, ItemGrant
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,
'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,
'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,
'created_at':item.created_at.isoformat(),'updated_at':item.updated_at.isoformat(),
}
if user is not None:
data['can_write'] = can_write_space(user, item.space)
data['can_write'] = writable(item,user)
return data
@ -45,7 +45,10 @@ def chain(item):
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
if not allow_trashed and any(n.trashed_at is not None for n in chain(item)):
return False

View File

@ -14,11 +14,16 @@ def is_admin(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()
if obj:
(Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True)
return obj
# Invitation-only users access an existing space through a SpaceGrant.
# They must never create another numbered shared directory implicitly.
grant = SpaceGrant.objects.filter(user=user).select_related('space').order_by('created_at','pk').first()
if grant:
return grant.space
with transaction.atomic():
obj = DriveSpace.objects.select_for_update().filter(user=user, private_owner__isnull=True).first()
if obj:
@ -39,8 +44,9 @@ def _make_numbered_space(parent, **fields):
private = fields.get('private_owner') is not None
if parent is None or parent.private_owner_id:
raise ValueError('Un espace doit avoir un parent partagé.')
if private and parent.user_id != fields['private_owner'].pk:
raise ValueError('L’espace privé doit être rattaché au quota attribué à son propriétaire.')
if private and parent.user_id != fields['private_owner'].pk and not SpaceGrant.objects.filter(
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
if len(base) + 1 + 24 > 700:
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:
return True
own = ensure_space(user)
if own.pk == space.pk:
if own.pk == space.pk and own.user_id == user.pk:
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 _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:
return True
own = ensure_space(user)
if own.pk == space.pk:
if own.pk == space.pk and own.user_id == user.pk:
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
grant = _grant(user, space)
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,
'is_private': bool(space.private_owner_id),
'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space),
'can_invite': can_manage_space(user, space), '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),
'can_view_parent': space.can_view_parent, 'can_write_parent': space.can_write_parent,
'created_at': space.created_at.isoformat(),

View 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)

View 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')

View File

@ -1,8 +1,14 @@
from django.urls import path
from . import views
from . import views_spaces
from . import views_spaces, views_shares
urlpatterns=[
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/<int:space_id>/',views_spaces.edit_shared_space),
path('spaces/me/',views_spaces.rename_my_space),

View File

@ -7,7 +7,7 @@ from django.db.models import Sum, Count
from django.utils import timezone
from rest_framework.decorators import api_view
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 .quotas import quota_state
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):
user=request.user
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)
q=request.query_params.get('q','').strip()
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.
if view=='trash':
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)
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:
try:space=resolve_space(user,request.query_params.get('space'))
except PermissionError as ex:return error(str(ex),403)
@ -47,7 +58,7 @@ def listing(request):
parent=get_item(parent_id)
if not parent.is_folder or parent.space_id!=space.pk or not readable(parent,user):
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:
nodes=nodes.filter(name__icontains=q)
elif parent:
@ -58,12 +69,18 @@ def listing(request):
for n in nodes[:501]:
if readable(n,user,allow_trashed=view=='trash'):
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)
if len(items)>=500:break
trail=[]
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})
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})

View 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

View File

@ -17,7 +17,7 @@ from rest_framework.throttling import AnonRateThrottle
from rest_framework.response import Response
from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession
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,
assert_new_child_quota, assert_updated_quota)
@ -27,10 +27,20 @@ def permission_flags(data):
def manager(user, parent):
# Administrators can manage any descendant; delegates their own direct children only.
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):
return {'id':str(inv.pk), 'created_at':inv.created_at.isoformat(),
'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)]
else:
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.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))
@ -60,6 +70,8 @@ def spaces(request):
@api_view(['PATCH'])
def rename_my_space(request):
own=my_space(request.user)
if own.user_id != request.user.pk and not is_admin(request.user):
return error('Impossible de renommer un espace attribué à un autre compte.',403)
try:
title=valid_name(request.data.get('display_name'))
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)
if child.can_write_parent and not child.can_view_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:
try:_update_quota(child, request.data['quota_bytes'])
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)
fields=permission_flags(request.data)
if 'can_view_parent' not in request.data:fields['can_view_parent']=True
if fields['can_write_parent'] and not fields['can_view_parent']:
return error('L’écriture nécessite la lecture de l’espace parent.')
if not fields['can_view_parent']:
return error('L’invitation donne nécessairement accès en lecture à cet espace.')
try:enforce_delegation(request.user,parent,view=fields['can_view_parent'],write=fields['can_write_parent'],invite=fields['can_invite'])
except ValueError as exc:return error(str(exc),403)
try:days=int(request.data.get('expires_days',7))
except (ValueError,TypeError):return error('Durée invalide.')
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)
username=str(request.data.get('username','')).strip()
password=request.data.get('password','')
display_name=str(request.data.get('display_name','')).strip()
email=str(request.data.get('email','')).strip()
first_name=str(request.data.get('first_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:
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:
return error('Nom d’utilisateur et nom d’espace obligatoires (150/120 caractères maximum).')
if not username or len(username)>150:
return error('Nom d’utilisateur obligatoire (150 caractères maximum).')
User=get_user_model()
try:
User._meta.get_field(User.USERNAME_FIELD).run_validators(username)
@ -226,15 +242,17 @@ def accept_invitation(request,token):
with transaction.atomic():
inv=get_valid_invitation(token,lock=True)
if inv is None:return error('Invitation invalide ou déjà utilisée.',404)
with locked_quota_tree(inv.parent_space):
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
space=create_space(user,inv.parent_space,display_name,
can_view_parent=inv.can_view_parent,can_write_parent=inv.can_write_parent,
can_invite=inv.can_invite)
enforce_delegation(inv.created_by,inv.parent_space,view=True,
write=inv.can_write_parent,invite=inv.can_invite)
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
SpaceGrant.objects.create(user=user,space=inv.parent_space,
can_write=inv.can_write_parent,can_invite=inv.can_invite)
space=inv.parent_space
ensure_private_space(user)
inv.used_by=user;inv.used_at=timezone.now()
inv.save(update_fields=['used_by','used_at'])
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)
@ -289,13 +307,15 @@ def add_existing_user(request):
return error('Utilisateur introuvable.',404)
if account.pk == parent.user_id or account.pk == request.user.pk:
return error('Impossible de vous ajouter vous-même à cet espace.')
own = ensure_space(account)
if own.parent_id == parent.pk:
own = DriveSpace.objects.filter(user=account, private_owner__isnull=True).first()
if own and own.parent_id == parent.pk:
return error('Cet utilisateur est déjà un sous-utilisateur direct de cet espace.')
can_write=request.data.get('can_write', False)
can_invite=request.data.get('can_invite', False)
if not isinstance(can_write, bool) or not isinstance(can_invite, bool):
return error('Permissions invalides.')
try:enforce_delegation(request.user,parent,write=can_write,invite=can_invite)
except ValueError as exc:return error(str(exc),403)
with transaction.atomic():
grant,created = SpaceGrant.objects.select_for_update().get_or_create(
user=account, space=parent, defaults={'can_write': can_write,'can_invite':can_invite})
@ -320,6 +340,8 @@ def edit_existing_user(request, grant_id):
if not isinstance(request.data[key],bool):
return error('Permission invalide.')
setattr(grant,key,request.data[key])
try:enforce_delegation(request.user,grant.space,write=grant.can_write,invite=grant.can_invite)
except ValueError as exc:return error(str(exc),403)
grant.save(update_fields=['can_write','can_invite'])
return Response(grant_info(grant))
@ -348,7 +370,8 @@ def create_shared_space(request):
def edit_shared_space(request,space_id):
try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if space.private_owner_id or not 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':
return delete_shared_space(request, space_id)
try:
@ -438,3 +461,24 @@ def delete_shared_space(request, space_id):
transaction.on_commit(lambda: _remove_deleted_space_files(
space.relative_path, legacy_locations, upload_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})

View File

@ -58,6 +58,14 @@ api.interceptors.response.use(response => response, async error => {
})
export const drive={
list:params=>api.get('/drive/items/',{params}).then(r=>r.data),
shareUsers:q=>api.get('/drive/shares/users/search/',{params:{q}}).then(r=>r.data.users),
itemShares:id=>api.get(`/drive/items/${id}/shares/`).then(r=>r.data),
shareItem:(id,user_id)=>api.post(`/drive/items/${id}/shares/`,{user_id}).then(r=>r.data),
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),
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),
@ -107,3 +115,5 @@ export const drive={
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 const publicFileUrl = (token,id) => `${api.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/`

View File

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
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) => {
if (!item) return []
@ -11,6 +11,7 @@ export const fileOptions = (item, trash) => {
return [
{ id: 'open', title: 'Ouvrir', icon: Eye },
...(!item.is_folder ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []),
...(item.can_write ? [{ id: 'share', title: 'Partager', icon: Share2 }] : []),
...(item.can_write ? [
{ id: 'rename', title: 'Renommer', icon: Pencil },
{ id: 'move', title: 'Déplacer', icon: FolderInput },
@ -20,7 +21,7 @@ export const fileOptions = (item, trash) => {
}
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},
...(space.has_account || space.is_private || !space.parent_id ? [] : [{id:'delete',title:'Supprimer l’espace',icon:Trash2,danger:true}]),
] : []

View File

@ -5,12 +5,14 @@ import PreviewDialog from './dialogs/PreviewDialog'
import SpaceSettingsDialog from './dialogs/SpaceSettingsDialog'
import MembersDialog from './dialogs/MembersDialog'
import DeleteSpaceDialog from './dialogs/DeleteSpaceDialog'
import ShareItemDialog from './dialogs/ShareItemDialog'
/** Groups the Drive's existing dialogs without coupling them to the page markup. */
export default function DriveDialogs({ controller, user }) {
return <>
<ItemFormDialog controller={controller} />
<ItemActionsDialog controller={controller} user={user} />
<ShareItemDialog controller={controller} />
<DeleteDialog controller={controller} />
<PreviewDialog controller={controller} />
<SpaceSettingsDialog controller={controller} />

View File

@ -31,7 +31,7 @@ export default function DriveExplorer({ controller, user }) {
const title = 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 (
<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">
{isTrash
? <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>}
{view === 'files' && !search && trail.map(folder => (
: <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' || view === 'shared') && !search && trail.map(folder => (
<React.Fragment key={folder.id}>
<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>
</React.Fragment>
))}
{view !== 'files' && <>
{view !== 'files' && view !== 'shared' && <>
<ChevronRight className="size-3.5" />
<span>{headerNames[view]}</span>
</>}
@ -96,13 +96,13 @@ export default function DriveExplorer({ controller, user }) {
<FolderOpen className="size-9 text-primary" />
</div>
<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>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
{isTrash
? 'Les fichiers supprimés apparaîtront ici avant leur suppression définitive.'
: 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>
{!isTrash && !search && canWrite && (
<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}
onChoose={type => performItemAction(context.item,type)} />
<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>
</footer>
</section>

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import ContextActions, {spaceOptions, mobileDots} from './ContextActions'
import { MoreHorizontal, Plus } from 'lucide-react'
import { 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 { fmtSize } from '@/lib/drive-format'
@ -103,7 +103,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
Explorer
</p>
<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
return (
<button

View File

@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button'
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 }) => (
<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>}
{!action.is_folder && <ActionButton icon={Download} onClick={() => handleAction('download')}>Télécharger</ActionButton>}
{action.can_write && !isTrash && <>
<ActionButton icon={Share2} onClick={() => handleAction('share')}>Partager</ActionButton>
<ActionButton icon={Pencil} onClick={() => handleAction('rename')}>Renommer</ActionButton>
<ActionButton icon={FolderInput} onClick={() => handleAction('move')}>Déplacer</ActionButton>
<ActionButton icon={Trash2} destructive onClick={() => handleAction('trash')}>Corbeille</ActionButton>
@ -32,7 +33,7 @@ export default function ItemActionsDialog({ controller: c }) {
</>}
</>}
</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>
</Dialog>
)

View 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>
}

View File

@ -1,3 +1,3 @@
import { Trash2 } from 'lucide-react'
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' }

View File

@ -53,7 +53,7 @@ export function useDriveController(user) {
}, [query])
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 () => {
try {
const data = await drive.spaces()
@ -79,11 +79,12 @@ export function useDriveController(user) {
const sequence = ++requestSequence.current
if (!silent) setLoading(true)
try {
if (view === 'users') { setLoading(false); return }
const params = { view, space: activeSpaceId }
if (view === 'files' && parent) params.parent = parent
if ((view === 'files' || view === 'shared') && parent) params.parent = parent
if (search) {
params.q = search
if (view === 'files') delete params.parent
if (view === 'files' || view === 'shared') delete params.parent
}
const data = await drive.list(params)
if (sequence !== requestSequence.current) return
@ -147,7 +148,7 @@ export function useDriveController(user) {
const openFolder = item => {
setParent(item.id)
setView('files')
setView(view === 'shared' ? 'shared' : 'files')
setQuery('')
setSidebar(false)
}
@ -252,7 +253,7 @@ export function useDriveController(user) {
})
const goRoot = () => {
setView('files')
setView(view === 'shared' ? 'shared' : 'files')
setParent(null)
setQuery('')
}

View 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>
}

View File

@ -5,6 +5,7 @@ import DriveToolbar from '@/components/drive/DriveToolbar'
import DriveExplorer from '@/components/drive/DriveExplorer'
import DriveDialogs from '@/components/drive/DriveDialogs'
import UploadTray from '@/components/drive/UploadTray'
import AdminUsersPage from '@/pages/AdminUsersPage'
export default function DrivePage({user, onLogout}) {
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)} />}
<DriveSidebar controller={controller} user={user} onLogout={onLogout} />
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DriveToolbar controller={controller} />
<DriveExplorer controller={controller} user={user} />
{controller.view === 'users' && user.is_admin ? <AdminUsersPage /> : <>
<DriveToolbar controller={controller} />
<DriveExplorer controller={controller} user={user} />
</>}
</main>
<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} />
<UploadTray uploads={controller.uploads} />
</div>

View File

@ -9,7 +9,6 @@ export default function InvitationPage({ token }) {
const [username, setUsername] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = 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 }
setBusy(true); setError('')
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)
} catch (exception) { setError(message(exception)) }
finally { setBusy(false) }
@ -39,12 +38,11 @@ export default function InvitationPage({ token }) {
alt="Logo de GooDrive"
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>
{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>
: 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>
{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 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">
<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">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">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>

View File

@ -13,9 +13,9 @@ const options = [
{ key: 'can_write_parent', label: 'Modifier les fichiers du parent' },
{ key: 'can_invite', label: 'Créer des sous-utilisateurs' },
]
function Permissions({ value, onChange, disabled = false }) {
function Permissions({ value, onChange, disabled = false, maxRights = {}, fixedRead = false }) {
return <div className="space-y-2">{options.map(opt => <label key={opt.key} className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1 text-sm">
<Checkbox disabled={disabled} 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>
}
@ -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">
<p className="font-semibold">Gestion de « {selectedManagedSpace.display_name} »</p>
</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>}
{!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>}
{selectedAccount && <div className="space-y-3 rounded-xl border p-3">
<p className="text-sm">Utilisateur sélectionné : <strong>{[selectedAccount.first_name,selectedAccount.last_name].filter(Boolean).join(' ')}</strong> (@{selectedAccount.username})</p>
<label className="flex items-center gap-2 text-sm"><Checkbox 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_write} checked={directRights.can_write} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_write:!!v}))}/> Autoriser les modifications dans cet espace</label>
<label className="flex items-center gap-2 text-sm"><Checkbox disabled={!selectedManagedSpace?.can_invite} checked={directRights.can_invite} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_invite:!!v}))}/> Autoriser la gestion des utilisateurs de cet espace</label>
<Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button>
</div>}
{!!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 className="rounded-2xl border bg-card p-5 space-y-4">
<div className="flex items-center gap-2 font-semibold"><KeyRound className="size-5 text-primary"/> Créer une invitation</div>
<Permissions value={rights} onChange={setRights}/>
<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
<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>
@ -193,7 +193,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
</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>
{!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 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>
@ -203,7 +203,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
</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 [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])
@ -214,22 +214,22 @@ function MemberRow({child,save,busy,onManage,onInvalid}) {
</div>
{child.can_invite && <Button type="button" size="sm" variant="outline" disabled={busy} onClick={onManage}>Gérer ce sous-espace</Button>}
</div>
return <div className="rounded-xl border p-4"><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div><div className="font-medium">{child.display_name}</div><div className="text-xs text-muted-foreground">{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" disabled={busy} onClick={()=>{try{save({...rights,...(quota === fromQuota(child.quota_bytes) ? {} : {quota_bytes:toQuota(quota)})})}catch(e){onInvalid(e.message)}}}><Check className="size-4"/> Appliquer</Button><Button size="sm" variant="outline" disabled={busy} onClick={()=>save({is_active:!child.is_active})}>{child.is_active?'Suspendre':'Réactiver'}</Button></div></div><Permissions value={rights} onChange={setRights}/>
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}/>
<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>
}
function DirectMember({grant,save,remove,busy}) {
function DirectMember({grant,save,remove,busy,maxRights}) {
const [canWrite,setCanWrite] = useState(grant.can_write)
const [canInvite,setCanInvite] = useState(grant.can_invite)
useEffect(()=>{setCanWrite(grant.can_write);setCanInvite(grant.can_invite)},[grant])
return <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3">
<div className="min-w-36"><div className="text-sm font-semibold">{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}</div><div className="text-xs text-muted-foreground">@{grant.username}</div></div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs"><Checkbox 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_write} checked={canWrite} onCheckedChange={v=>setCanWrite(!!v)}/> Écriture</label>
<label className="flex items-center gap-2 text-xs"><Checkbox disabled={!maxRights?.can_invite} checked={canInvite} onCheckedChange={v=>setCanInvite(!!v)}/> Invitations</label>
<Button size="sm" variant="outline" disabled={busy} onClick={()=>save({can_write:canWrite,can_invite:canInvite})}>Enregistrer</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={remove} title="Retirer l’accès"><Trash2 className="size-4" /></Button>
</div>

View 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>
}