From c11c6618979450817cf2820410e7f339fd348dae Mon Sep 17 00:00:00 2001
From: Louis Mazin
Date: Wed, 14 Oct 2026 15:58:46 +0200
Subject: [PATCH] test
---
backend/core/urls.py | 4 +-
backend/drive/migrations/0015_item_shares.py | 23 ++
backend/drive/models.py | 20 ++
backend/drive/services.py | 11 +-
backend/drive/spaces.py | 24 +-
backend/drive/test_item_sharing.py | 119 ++++++++
backend/drive/test_private_layout.py | 272 ++++++++++++++++++
backend/drive/urls.py | 8 +-
backend/drive/views_items.py | 29 +-
backend/drive/views_shares.py | 148 ++++++++++
backend/drive/views_spaces.py | 76 +++--
frontend/src/api/client.js | 10 +
.../src/components/drive/ContextActions.jsx | 5 +-
.../src/components/drive/DriveDialogs.jsx | 2 +
.../src/components/drive/DriveExplorer.jsx | 14 +-
.../src/components/drive/DriveSidebar.jsx | 4 +-
.../drive/dialogs/ItemActionsDialog.jsx | 5 +-
.../drive/dialogs/ShareItemDialog.jsx | 84 ++++++
frontend/src/config/navigation.js | 2 +-
frontend/src/hooks/useDriveController.js | 11 +-
frontend/src/pages/AdminUsersPage.jsx | 38 +++
frontend/src/pages/DrivePage.jsx | 9 +-
frontend/src/pages/InvitationPage.jsx | 8 +-
frontend/src/pages/MembersPage.jsx | 26 +-
frontend/src/pages/PublicSharePage.jsx | 41 +++
25 files changed, 917 insertions(+), 76 deletions(-)
create mode 100644 backend/drive/migrations/0015_item_shares.py
create mode 100644 backend/drive/test_item_sharing.py
create mode 100644 backend/drive/test_private_layout.py
create mode 100644 backend/drive/views_shares.py
create mode 100644 frontend/src/components/drive/dialogs/ShareItemDialog.jsx
create mode 100644 frontend/src/pages/AdminUsersPage.jsx
create mode 100644 frontend/src/pages/PublicSharePage.jsx
diff --git a/backend/core/urls.py b/backend/core/urls.py
index 9259728..96213c1 100644
--- a/backend/core/urls.py
+++ b/backend/core/urls.py
@@ -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//',views_shares.public_share),
+ path('api/public//files//',views_shares.public_file),
path('api/invitations//',views_spaces.invitation_preview),
path('api/invitations//accept/',views_spaces.accept_invitation),
path('django-admin/', admin.site.urls),
diff --git a/backend/drive/migrations/0015_item_shares.py b/backend/drive/migrations/0015_item_shares.py
new file mode 100644
index 0000000..e3da9f9
--- /dev/null
+++ b/backend/drive/migrations/0015_item_shares.py
@@ -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')),
+ ]),
+ ]
diff --git a/backend/drive/models.py b/backend/drive/models.py
index 141907d..89e7603 100644
--- a/backend/drive/models.py
+++ b/backend/drive/models.py
@@ -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)
diff --git a/backend/drive/services.py b/backend/drive/services.py
index 97551fc..498499c 100644
--- a/backend/drive/services.py
+++ b/backend/drive/services.py
@@ -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
diff --git a/backend/drive/spaces.py b/backend/drive/spaces.py
index cb90244..a5c6023 100644
--- a/backend/drive/spaces.py
+++ b/backend/drive/spaces.py
@@ -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(),
diff --git a/backend/drive/test_item_sharing.py b/backend/drive/test_item_sharing.py
new file mode 100644
index 0000000..1b5cd0a
--- /dev/null
+++ b/backend/drive/test_item_sharing.py
@@ -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)
diff --git a/backend/drive/test_private_layout.py b/backend/drive/test_private_layout.py
new file mode 100644
index 0000000..6925995
--- /dev/null
+++ b/backend/drive/test_private_layout.py
@@ -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')
diff --git a/backend/drive/urls.py b/backend/drive/urls.py
index fd4a93b..f0be772 100644
--- a/backend/drive/urls.py
+++ b/backend/drive/urls.py
@@ -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//shares/',views_shares.manage_item_shares),
+ path('items//shares//',views_shares.remove_item_share),
+ path('items//public-links/',views_shares.public_links),
+ path('items//public-links//',views_shares.revoke_public_link),
path('spaces/create/',views_spaces.create_shared_space),
path('spaces//',views_spaces.edit_shared_space),
path('spaces/me/',views_spaces.rename_my_space),
diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py
index ad8e304..3190463 100644
--- a/backend/drive/views_items.py
+++ b/backend/drive/views_items.py
@@ -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})
diff --git a/backend/drive/views_shares.py b/backend/drive/views_shares.py
new file mode 100644
index 0000000..17ccd0b
--- /dev/null
+++ b/backend/drive/views_shares.py
@@ -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
diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py
index e52a021..3a535e6 100644
--- a/backend/drive/views_spaces.py
+++ b/backend/drive/views_spaces.py
@@ -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})
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index eb80789..f69904c 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -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}/`
diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx
index 3c64c36..813cb08 100644
--- a/frontend/src/components/drive/ContextActions.jsx
+++ b/frontend/src/components/drive/ContextActions.jsx
@@ -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}]),
] : []
diff --git a/frontend/src/components/drive/DriveDialogs.jsx b/frontend/src/components/drive/DriveDialogs.jsx
index 5a0ba64..0f435eb 100644
--- a/frontend/src/components/drive/DriveDialogs.jsx
+++ b/frontend/src/components/drive/DriveDialogs.jsx
@@ -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 <>
+
diff --git a/frontend/src/components/drive/DriveExplorer.jsx b/frontend/src/components/drive/DriveExplorer.jsx
index 4889e23..b4a7de5 100644
--- a/frontend/src/components/drive/DriveExplorer.jsx
+++ b/frontend/src/components/drive/DriveExplorer.jsx
@@ -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 (
@@ -45,14 +45,14 @@ export default function DriveExplorer({ controller, user }) {
{isTrash
? Tous les espaces accessibles
- : {activeSpace?.is_private ? 'Mes fichiers' : activeSpace?.display_name || 'Mes fichiers'} }
- {view === 'files' && !search && trail.map(folder => (
+ : {view === 'shared' ? 'Partagés avec moi' : activeSpace?.is_private ? 'Mes fichiers' : activeSpace?.display_name || 'Mes fichiers'} }
+ {(view === 'files' || view === 'shared') && !search && trail.map(folder => (
setParent(folder.id)}>{folder.name}
))}
- {view !== 'files' && <>
+ {view !== 'files' && view !== 'shared' && <>
{headerNames[view]}
>}
@@ -96,13 +96,13 @@ export default function DriveExplorer({ controller, user }) {
- {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'}
{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.'}
{!isTrash && !search && canWrite && (
filesInput.current?.click()}>
@@ -120,7 +120,7 @@ export default function DriveExplorer({ controller, user }) {
performItemAction(context.item,type)} />
- Clic droit sur un élément pour les actions. Tes fichiers restent sur ton serveur.
+ Clic droit sur un élément pour les actions.
{!isTrash && stats ? `${stats.folders} dossiers · ${stats.files} fichiers` : ''}
diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx
index b80fc0d..989250b 100644
--- a/frontend/src/components/drive/DriveSidebar.jsx
+++ b/frontend/src/components/drive/DriveSidebar.jsx
@@ -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
- {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 (
(
@@ -22,6 +22,7 @@ export default function ItemActionsDialog({ controller: c }) {
{!isTrash && handleAction('open')}>Ouvrir }
{!action.is_folder && handleAction('download')}>Télécharger }
{action.can_write && !isTrash && <>
+ handleAction('share')}>Partager
handleAction('rename')}>Renommer
handleAction('move')}>Déplacer
handleAction('trash')}>Corbeille
@@ -32,7 +33,7 @@ export default function ItemActionsDialog({ controller: c }) {
>}
>}
- Les fichiers restent privés. Seuls les membres autorisés de cet espace peuvent y accéder.
+
)
diff --git a/frontend/src/components/drive/dialogs/ShareItemDialog.jsx b/frontend/src/components/drive/dialogs/ShareItemDialog.jsx
new file mode 100644
index 0000000..c73d928
--- /dev/null
+++ b/frontend/src/components/drive/dialogs/ShareItemDialog.jsx
@@ -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 {if(!v)setModal(null)}}>
+
+ Partager « {action?.name} »
+
+
+
+ Accès public
+ {busy? : } Générer un lien public
+ {link &&
}
+ {record.links.length>0 && {record.links.map(publicLink=>
+ Lien créé le {new Date(publicLink.created_at).toLocaleDateString('fr-FR')}
+ perform(()=>drive.revokePublic(itemId,publicLink.id))}>
+
)}
}
+
+ {error &&
{error}
}
+
+
+
+}
diff --git a/frontend/src/config/navigation.js b/frontend/src/config/navigation.js
index f72e8c6..1779cad 100644
--- a/frontend/src/config/navigation.js
+++ b/frontend/src/config/navigation.js
@@ -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' }
diff --git a/frontend/src/hooks/useDriveController.js b/frontend/src/hooks/useDriveController.js
index b54653a..c838cf4 100644
--- a/frontend/src/hooks/useDriveController.js
+++ b/frontend/src/hooks/useDriveController.js
@@ -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('')
}
diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx
new file mode 100644
index 0000000..a1885c2
--- /dev/null
+++ b/frontend/src/pages/AdminUsersPage.jsx
@@ -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
+
+
Utilisateurs
+
setQuery(e.target.value)}/>
+ {error &&
{error}
}
+ {busy?
:
+
Utilisateur Identifiant E-mail Espace attribué Statut
+ {record.users.map(user=>
+ {[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'}
+ @{user.username} {user.email||'—'} {user.space_name||'—'}
+ {user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''}
+ )}
+ {!record.users.length &&
Aucun utilisateur.
}
+
}
+ {record.limited &&
200 résultats affichés. Précise la recherche.
}
+
+
+}
diff --git a/frontend/src/pages/DrivePage.jsx b/frontend/src/pages/DrivePage.jsx
index aaf279c..fe54d27 100644
--- a/frontend/src/pages/DrivePage.jsx
+++ b/frontend/src/pages/DrivePage.jsx
@@ -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 && setSidebar(false)} />}
-
-
+ {controller.view === 'users' && user.is_admin ? : <>
+
+
+ >}
uploadFiles(e.target.files)} />
- {drag && !isTrash && canWrite && Dépose tes fichiers ici Ils seront importés sur ton serveur.
}
+ {drag && !isTrash && canWrite && }
diff --git a/frontend/src/pages/InvitationPage.jsx b/frontend/src/pages/InvitationPage.jsx
index b5e62b6..0fffce7 100644
--- a/frontend/src/pages/InvitationPage.jsx
+++ b/frontend/src/pages/InvitationPage.jsx
@@ -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"
/>GoodDrive Un espace pour ceux qui en ont besoin.
- {done ? Bienvenue sur GoodDrive ! Ton espace personnel et ton espace partagé sont prêts. Tu peux maintenant te connecter.
window.location.assign('/')} className="w-full">Se connecter
- : info ? <>Créer mon espace Tu as été invité par {info.inviter_name} . Tu auras un espace privé de 50 Go à disposition, ainsi qu'un accès à l'espace « {info.parent_name} ».
+ {done ? Bienvenue sur GoodDrive ! Ton espace privé et ton accès à l’espace partagé sont prêts. Tu peux maintenant te connecter.
window.location.assign('/')} className="w-full">Se connecter
+ : info ? <>Créer mon compte Tu as été invité par {info.inviter_name} . Tu auras un espace privé de 50 Go à disposition, ainsi qu'un accès à l'espace « {info.parent_name} ».