From 22c7eac3aa825769e2cac905a4b08bad365721e6 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 23 Jul 2026 16:59:18 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20move=20descen?= =?UTF-8?q?dants=20access=20sync=20to=20a=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The descendants access synchronization logic was private to the accesses viewset. Extracting it into core/services/accesses.py lets the upcoming batch share endpoint reuse it without reaching into viewset internals. --- src/backend/core/api/viewsets.py | 30 +++----------------------- src/backend/core/services/accesses.py | 31 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 27 deletions(-) create mode 100644 src/backend/core/services/accesses.py diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index bf9d6ba1f..e18ae7213 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -47,6 +47,7 @@ from core import enums, models from core.entitlements import get_entitlements_backend +from core.services.accesses import synchronize_descendants_accesses from core.services.item_exports import build_zip_stream, export_descendants from core.services.sdk_relay import SDKRelayManager from core.services.search_indexers import ( @@ -1954,7 +1955,7 @@ def update(self, request, *args, **kwargs): access = serializer.save() - self._syncronize_descendants_accesses(access) + synchronize_descendants_accesses(self.item, access) if access.role != old_role: posthog_capture( @@ -2015,7 +2016,7 @@ def perform_create(self, serializer): ) access = serializer.save(item_id=self.kwargs["resource_id"]) - self._syncronize_descendants_accesses(access) + synchronize_descendants_accesses(self.item, access) if access.user: access.item.send_invitation_email( access.user.email, @@ -2050,31 +2051,6 @@ def perform_destroy(self, instance): item=item, ) - def _syncronize_descendants_accesses(self, access): - """ - Syncronize the accesses of the descendants of the item - by removing accesses with roles lower than the current user's role. - """ - descendants = self.item.descendants().filter(ancestors_deleted_at__isnull=True) - - condition_filter = db.Q() - if access.user: - condition_filter |= db.Q(user=access.user) - if access.team: - condition_filter |= db.Q(team=access.team) - - role_priority = models.RoleChoices.get_priority(access.role) - - lower_roles = [ - role - for role in models.RoleChoices.values - if models.RoleChoices.get_priority(role) <= role_priority - ] - - models.ItemAccess.objects.filter( - condition_filter, item__in=descendants, role__in=lower_roles - ).delete() - class InvitationViewset( drf.mixins.CreateModelMixin, diff --git a/src/backend/core/services/accesses.py b/src/backend/core/services/accesses.py new file mode 100644 index 000000000..fa88086a9 --- /dev/null +++ b/src/backend/core/services/accesses.py @@ -0,0 +1,31 @@ +"""Service for sharing items with registered users and inviting contacts.""" + +from django.db import models as db + +from core import models + + +def synchronize_descendants_accesses(item, access): + """ + Syncronize the accesses of the descendants of the item + by removing accesses with roles lower than the current user's role. + """ + descendants = item.descendants().filter(ancestors_deleted_at__isnull=True) + + condition_filter = db.Q() + if access.user: + condition_filter |= db.Q(user=access.user) + if access.team: + condition_filter |= db.Q(team=access.team) + + role_priority = models.RoleChoices.get_priority(access.role) + + lower_roles = [ + role + for role in models.RoleChoices.values + if models.RoleChoices.get_priority(role) <= role_priority + ] + + models.ItemAccess.objects.filter( + condition_filter, item__in=descendants, role__in=lower_roles + ).delete() From 16f11c12e7e5db2329b62cc176bf341b134dd71a Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 23 Jul 2026 16:59:31 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8(backend)=20add=20an=20item=20batc?= =?UTF-8?q?h=20share=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a folder with many contacts one call at a time is slow and can leave a partial state when one of them fails. This endpoint accepts a list of email/role rows, validates them all before any write, then creates accesses for known users and invitations for unknown emails. Rows targeting users already covered by an equal or higher role are skipped and reported so the client can inform the user. The feature is disabled by default and gated by the new ALLOW_SHARE_IMPORT_FILE setting exposed in the config endpoint. --- CHANGELOG.md | 1 + docs/env.md | 1 + src/backend/core/api/permissions.py | 1 + src/backend/core/api/serializers.py | 20 + src/backend/core/api/viewsets.py | 86 +++- src/backend/core/services/accesses.py | 65 +++ .../tests/items/test_api_item_batch_share.py | 376 ++++++++++++++++++ src/backend/core/tests/test_api_config.py | 2 + src/backend/drive/settings.py | 4 + 9 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 src/backend/core/tests/items/test_api_item_batch_share.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e065439d..78cd3e4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to - ✨(backend) make the upload ACL configurable to support GCS based storages - ✨(frontend) show the messages widget button on the homepage - ✨(frontend) open the messages widget from the help menu +- ✨(backend) add an item batch share endpoint gated by ALLOW_SHARE_IMPORT_FILE ### Changed diff --git a/docs/env.md b/docs/env.md index 7b2d92e1b..6f1062467 100644 --- a/docs/env.md +++ b/docs/env.md @@ -7,6 +7,7 @@ This document lists all configurable environment variables for the Drive applica |---------------------|-------------|---------------| | `ALLOWED_HOSTS` | List of allowed hosts for the application (used in Production) | `[]` | | `ALLOW_LOGOUT_GET_METHOD` | Allow logout via GET method | `True` | +| `ALLOW_SHARE_IMPORT_FILE` | Enable batch sharing of an item from an imported contacts file | `False` | | `API_USERS_LIST_LIMIT` | Maximum number of users returned in API user list | `5` | | `API_USERS_LIST_THROTTLE_RATE_BURST` | Burst throttle rate for user list API | `30/minute` | | `API_USERS_LIST_THROTTLE_RATE_SUSTAINED` | Sustained throttle rate for user list API | `180/hour` | diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 1e8aad414..3a8a74f9f 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -11,6 +11,7 @@ ACTION_FOR_METHOD_TO_PERMISSION = { "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}, "children": {"GET": "children_list", "POST": "children_create"}, + "batch_share": {"POST": "accesses_manage"}, } diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index cbddbf8d3..a01eb217b 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -871,6 +871,26 @@ class MoveItemSerializer(serializers.Serializer): target_item_id = serializers.UUIDField(required=False) +BATCH_SHARE_MAX_ROWS = 100 # Keep in sync with the ui-kit share import modal max rows + + +class BatchShareRowSerializer(serializers.Serializer): + """One row of a batch share payload: a contact email and the role to grant.""" + + email = serializers.EmailField() + role = serializers.ChoiceField(choices=models.RoleChoices.choices) + + def validate_email(self, value): + """Normalize emails to lower case like invitations do.""" + return value.lower() + + +class BatchShareSerializer(serializers.Serializer): + """Validate the payload of the item batch-share action.""" + + rows = BatchShareRowSerializer(many=True, allow_empty=False, max_length=BATCH_SHARE_MAX_ROWS) + + class SDKRelayEventSerializer(serializers.Serializer): """Serializer for SDK relay events.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index e18ae7213..51a8f3f10 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -31,7 +31,7 @@ ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ) -from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer from lasuite.drf.models.choices import ( PRIVILEGED_ROLES, LinkReachChoices, @@ -47,7 +47,10 @@ from core import enums, models from core.entitlements import get_entitlements_backend -from core.services.accesses import synchronize_descendants_accesses +from core.services.accesses import ( + batch_share_process_rows, + synchronize_descendants_accesses, +) from core.services.item_exports import build_zip_stream, export_descendants from core.services.sdk_relay import SDKRelayManager from core.services.search_indexers import ( @@ -1489,6 +1492,84 @@ def link_configuration(self, request, *args, **kwargs): return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) + @extend_schema( + request=serializers.BatchShareSerializer, + responses={ + 200: inline_serializer( + name="BatchShareResponse", + fields={ + "accesses_created": drf.serializers.IntegerField(), + "invitations_created": drf.serializers.IntegerField(), + "skipped": drf.serializers.ListField(child=drf.serializers.DictField()), + }, + ) + }, + ) + @drf.decorators.action(detail=True, methods=["post"], url_path="batch-share") + def batch_share(self, request, *args, **kwargs): + """ + Share an item with a list of contacts in a single request. + + Emails matching an existing user get an access, unknown emails get an + invitation. All rows are validated before any database write so a + rejected batch never creates a partial share state. + """ + if not settings.ALLOW_SHARE_IMPORT_FILE: + raise drf.exceptions.PermissionDenied( + "Batch sharing from an imported file is not enabled." + ) + + item = self.get_object() + + serializer = serializers.BatchShareSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Deduplicate rows by email, keeping the first occurrence + rows = {} + for row in serializer.validated_data["rows"]: + rows.setdefault(row["email"], row["role"]) + + # A user cannot grant a role higher than their own. This also enforces + # that only owners can assign the owner role. + user_role_priority = models.RoleChoices.get_priority(item.get_role(request.user)) + for role in rows.values(): + if models.RoleChoices.get_priority(role) > user_role_priority: + raise drf.exceptions.PermissionDenied( + f"You cannot grant the role {role} which is higher than your own role." + ) + + created_accesses, created_invitations, skipped = batch_share_process_rows( + item, request.user, rows + ) + + for email, role in created_accesses + created_invitations: + item.send_invitation_email( + email, + role, + request.user, + request.user.language or settings.LANGUAGE_CODE, + ) + + posthog_capture( + "item_batch_share", + request.user, + { + "accesses_created": len(created_accesses), + "invitations_created": len(created_invitations), + "skipped": len(skipped), + }, + item=item, + ) + + return drf.response.Response( + { + "accesses_created": len(created_accesses), + "invitations_created": len(created_invitations), + "skipped": skipped, + }, + status=drf.status.HTTP_200_OK, + ) + @drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") def favorite(self, request, *args, **kwargs): """ @@ -2246,6 +2327,7 @@ def get(self, request): Return a dictionary of public settings. """ array_settings = [ + "ALLOW_SHARE_IMPORT_FILE", "AWS_S3_UPLOAD_ACL", "CRISP_WEBSITE_ID", "DATA_UPLOAD_MAX_MEMORY_SIZE", diff --git a/src/backend/core/services/accesses.py b/src/backend/core/services/accesses.py index fa88086a9..3aa2faa57 100644 --- a/src/backend/core/services/accesses.py +++ b/src/backend/core/services/accesses.py @@ -1,10 +1,75 @@ """Service for sharing items with registered users and inviting contacts.""" from django.db import models as db +from django.db import transaction +from django.db.models.functions import Lower from core import models +def batch_share_process_rows(item, issuer, rows): + """ + Create the accesses and invitations for the given {email: role} mapping. + + Emails matching an existing user get an access unless they already hold an + equal or higher role on the item or one of its ancestors, other emails get + an invitation unless one already exists. Return the created shares and the + skipped emails. + """ + users_by_email = {} + users_queryset = models.User.objects.annotate(email_lower=Lower("email")).filter( + email_lower__in=rows.keys() + ) + for user in users_queryset: + users_by_email.setdefault(user.email.lower(), user) + + # Compute the max role each matched user already holds on the item or one + # of its ancestors, mirroring the single access creation checks. Users with + # an explicit access on the item itself are always skipped as the access is + # unique per user and item. + ancestor_qs = (item.ancestors() | models.Item.objects.filter(pk=item.pk)).filter( + ancestors_deleted_at__isnull=True + ) + max_role_by_user_id = {} + users_with_explicit_access = set() + existing_accesses = models.ItemAccess.objects.filter( + item__in=ancestor_qs, user__in=users_by_email.values() + ).values_list("user_id", "item_id", "role") + for user_id, item_id, role in existing_accesses: + max_role_by_user_id[user_id] = models.RoleChoices.max( + max_role_by_user_id.get(user_id), role + ) + if item_id == item.pk: + users_with_explicit_access.add(user_id) + + already_invited = set( + item.invitations.filter(email__in=rows.keys()).values_list("email", flat=True) + ) + + skipped = [] + created_accesses = [] + created_invitations = [] + with transaction.atomic(): + for email, role in rows.items(): + if user := users_by_email.get(email): + max_ancestors_role = max_role_by_user_id.get(user.id) + if user.id in users_with_explicit_access or models.RoleChoices.get_priority( + max_ancestors_role + ) >= models.RoleChoices.get_priority(role): + skipped.append({"email": email, "reason": "already_shared"}) + continue + access = models.ItemAccess.objects.create(item=item, user=user, role=role) + synchronize_descendants_accesses(item, access) + created_accesses.append((email, role)) + elif email in already_invited: + skipped.append({"email": email, "reason": "already_invited"}) + else: + models.Invitation.objects.create(item=item, email=email, role=role, issuer=issuer) + created_invitations.append((email, role)) + + return created_accesses, created_invitations, skipped + + def synchronize_descendants_accesses(item, access): """ Syncronize the accesses of the descendants of the item diff --git a/src/backend/core/tests/items/test_api_item_batch_share.py b/src/backend/core/tests/items/test_api_item_batch_share.py new file mode 100644 index 000000000..70c55785f --- /dev/null +++ b/src/backend/core/tests/items/test_api_item_batch_share.py @@ -0,0 +1,376 @@ +""" +Test the item batch share API endpoint in drive's core app. +""" + +from django.core import mail +from django.test import override_settings + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def batch_share_url(item): + """Return the batch share url for the given item.""" + return f"/api/v1.0/items/{item.id!s}/batch-share/" + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=False) +def test_api_item_batch_share_setting_disabled(): + """The endpoint should be rejected when the feature setting is disabled.""" + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "owner")]) + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + {"rows": [{"email": "contact@example.com", "role": "reader"}]}, + format="json", + ) + + assert response.status_code == 403 + assert models.ItemAccess.objects.filter(item=item).count() == 1 + assert models.Invitation.objects.count() == 0 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_anonymous(): + """Anonymous users should not be allowed to batch share an item.""" + item = factories.ItemFactory() + + response = APIClient().post( + batch_share_url(item), + {"rows": [{"email": "contact@example.com", "role": "reader"}]}, + format="json", + ) + + assert response.status_code == 401 + assert models.Invitation.objects.count() == 0 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_authenticated_unrelated(): + """Users unrelated to the item should not be allowed to batch share it.""" + user = factories.UserFactory() + item = factories.ItemFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + {"rows": [{"email": "contact@example.com", "role": "reader"}]}, + format="json", + ) + + assert response.status_code == 403 + assert models.Invitation.objects.count() == 0 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +@pytest.mark.parametrize("role", ["reader", "editor"]) +def test_api_item_batch_share_authenticated_reader_or_editor(role): + """Readers and editors of an item should not be allowed to batch share it.""" + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, role)]) + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + {"rows": [{"email": "contact@example.com", "role": "reader"}]}, + format="json", + ) + + assert response.status_code == 403 + assert models.Invitation.objects.count() == 0 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +@pytest.mark.parametrize("role", ["administrator", "owner"]) +def test_api_item_batch_share_privileged_mixed_rows(role, settings): + """ + Administrators and owners should be able to batch share an item. Emails + matching an existing user get an access, unknown emails get an invitation, + and every created share triggers a notification email. + """ + user = factories.UserFactory(language=settings.LANGUAGE_CODE) + item = factories.ItemFactory(users=[(user, role)]) + existing_user = factories.UserFactory(email="alice@example.com") + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + { + "rows": [ + {"email": "alice@example.com", "role": "editor"}, + {"email": "bob@example.com", "role": "reader"}, + ] + }, + format="json", + ) + + assert response.status_code == 200 + assert response.json() == { + "accesses_created": 1, + "invitations_created": 1, + "skipped": [], + } + assert models.ItemAccess.objects.filter(item=item, user=existing_user, role="editor").exists() + assert models.Invitation.objects.filter( + item=item, email="bob@example.com", role="reader", issuer=user + ).exists() + assert len(mail.outbox) == 2 + assert sorted(email.to[0] for email in mail.outbox) == [ + "alice@example.com", + "bob@example.com", + ] + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_user_email_case_insensitive(): + """Emails should match existing users regardless of their case.""" + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "owner")]) + existing_user = factories.UserFactory(email="Alice@Example.com") + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + {"rows": [{"email": "alice@example.com", "role": "editor"}]}, + format="json", + ) + + assert response.status_code == 200 + assert response.json()["accesses_created"] == 1 + assert models.ItemAccess.objects.filter(item=item, user=existing_user).exists() + assert models.Invitation.objects.count() == 0 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_already_shared(): + """Users already having an access with an equal or higher role should be skipped.""" + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "owner")]) + shared_user = factories.UserFactory(email="alice@example.com") + factories.UserItemAccessFactory(item=item, user=shared_user, role="editor") + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + {"rows": [{"email": "alice@example.com", "role": "editor"}]}, + format="json", + ) + + assert response.status_code == 200 + assert response.json() == { + "accesses_created": 0, + "invitations_created": 0, + "skipped": [{"email": "alice@example.com", "reason": "already_shared"}], + } + assert models.ItemAccess.objects.filter(item=item, user=shared_user).count() == 1 + assert len(mail.outbox) == 0 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_inherited_access(): + """ + Users with an inherited role higher or equal to the requested one should be + skipped while a higher requested role creates an explicit access. + """ + user = factories.UserFactory() + parent = factories.ItemFactory(users=[(user, "owner")], type=models.ItemTypeChoices.FOLDER) + item = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + editor = factories.UserFactory(email="editor@example.com") + reader = factories.UserFactory(email="reader@example.com") + factories.UserItemAccessFactory(item=parent, user=editor, role="editor") + factories.UserItemAccessFactory(item=parent, user=reader, role="reader") + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + { + "rows": [ + {"email": "editor@example.com", "role": "reader"}, + {"email": "reader@example.com", "role": "editor"}, + ] + }, + format="json", + ) + + assert response.status_code == 200 + assert response.json() == { + "accesses_created": 1, + "invitations_created": 0, + "skipped": [{"email": "editor@example.com", "reason": "already_shared"}], + } + assert models.ItemAccess.objects.filter(item=item, user=reader, role="editor").exists() + assert not models.ItemAccess.objects.filter(item=item, user=editor).exists() + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_already_invited(): + """Emails already invited to the item should be skipped.""" + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "owner")]) + factories.InvitationFactory(item=item, email="bob@example.com", role="reader") + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + {"rows": [{"email": "bob@example.com", "role": "editor"}]}, + format="json", + ) + + assert response.status_code == 200 + assert response.json() == { + "accesses_created": 0, + "invitations_created": 0, + "skipped": [{"email": "bob@example.com", "reason": "already_invited"}], + } + assert models.Invitation.objects.filter(item=item).count() == 1 + assert len(mail.outbox) == 0 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_duplicated_rows(): + """Duplicated emails in the payload should only create one share.""" + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "owner")]) + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + { + "rows": [ + {"email": "bob@example.com", "role": "editor"}, + {"email": "Bob@example.com", "role": "reader"}, + ] + }, + format="json", + ) + + assert response.status_code == 200 + assert response.json()["invitations_created"] == 1 + assert ( + models.Invitation.objects.filter(item=item, email="bob@example.com", role="editor").count() + == 1 + ) + assert len(mail.outbox) == 1 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_administrator_cannot_grant_owner(): + """ + An administrator should not be allowed to grant a role higher than their + own and no row of the batch should be created in that case. + """ + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "administrator")]) + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + { + "rows": [ + {"email": "bob@example.com", "role": "reader"}, + {"email": "carol@example.com", "role": "owner"}, + ] + }, + format="json", + ) + + assert response.status_code == 403 + assert models.Invitation.objects.count() == 0 + assert len(mail.outbox) == 0 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_owner_can_grant_owner(): + """Owners should be allowed to grant the owner role.""" + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "owner")]) + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + {"rows": [{"email": "bob@example.com", "role": "owner"}]}, + format="json", + ) + + assert response.status_code == 200 + assert models.Invitation.objects.filter(item=item, role="owner").count() == 1 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +@pytest.mark.parametrize( + "rows", + [ + [], + [{"email": "not-an-email", "role": "reader"}], + [{"email": "bob@example.com", "role": "unknown-role"}], + [{"email": "bob@example.com"}], + [{"email": f"contact{index}@example.com", "role": "reader"} for index in range(101)], + ], +) +def test_api_item_batch_share_invalid_payload(rows): + """Invalid payloads should be rejected without creating anything.""" + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "owner")]) + + client = APIClient() + client.force_login(user) + + response = client.post(batch_share_url(item), {"rows": rows}, format="json") + + assert response.status_code == 400 + assert models.Invitation.objects.count() == 0 + assert models.ItemAccess.objects.filter(item=item).count() == 1 + + +@override_settings(ALLOW_SHARE_IMPORT_FILE=True) +def test_api_item_batch_share_synchronizes_descendants(): + """ + Explicit accesses on descendants with a role lower or equal to the new one + should be removed like when creating a single access. + """ + user = factories.UserFactory() + item = factories.ItemFactory(users=[(user, "owner")], type=models.ItemTypeChoices.FOLDER) + child = factories.ItemFactory(parent=item, type=models.ItemTypeChoices.FOLDER) + shared_user = factories.UserFactory(email="alice@example.com") + factories.UserItemAccessFactory(item=child, user=shared_user, role="reader") + + client = APIClient() + client.force_login(user) + + response = client.post( + batch_share_url(item), + {"rows": [{"email": "alice@example.com", "role": "editor"}]}, + format="json", + ) + + assert response.status_code == 200 + assert models.ItemAccess.objects.filter(item=item, user=shared_user, role="editor").exists() + assert not models.ItemAccess.objects.filter(item=child, user=shared_user).exists() diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index c3ab85940..a2d8a4ccf 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -18,6 +18,7 @@ @override_settings( + ALLOW_SHARE_IMPORT_FILE=True, AWS_S3_UPLOAD_ACL="private", CRISP_WEBSITE_ID="123", DATA_UPLOAD_MAX_MEMORY_SIZE=2048, @@ -61,6 +62,7 @@ def test_api_config(is_authenticated): response = client.get("/api/v1.0/config/") assert response.status_code == HTTP_200_OK assert response.json() == { + "ALLOW_SHARE_IMPORT_FILE": True, "AWS_S3_UPLOAD_ACL": "private", "CRISP_WEBSITE_ID": "123", "DATA_UPLOAD_MAX_MEMORY_SIZE": 2048, diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index 977d5bea7..c12d1c45f 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -998,6 +998,10 @@ class Base(Configuration): # Sentry SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None) + ALLOW_SHARE_IMPORT_FILE = values.BooleanValue( + default=False, environ_name="ALLOW_SHARE_IMPORT_FILE", environ_prefix=None + ) + # Frontend FRONTEND_THEME = values.Value(None, environ_name="FRONTEND_THEME", environ_prefix=None) FRONTEND_MORE_LINK = values.Value( From 41e796b2a92499e173a086d7a292a0ba4e24e4b8 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 23 Jul 2026 16:59:45 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=E2=AC=86=EF=B8=8F(frontend)=20bump=20ui-ki?= =?UTF-8?q?t=20for=20the=20share=20import=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share import modal needs ui-kit latest version. --- src/frontend/apps/drive/package.json | 4 +-- src/frontend/yarn.lock | 48 ++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/frontend/apps/drive/package.json b/src/frontend/apps/drive/package.json index 8f1deefad..4f941c5a1 100644 --- a/src/frontend/apps/drive/package.json +++ b/src/frontend/apps/drive/package.json @@ -16,8 +16,8 @@ "yarn": "1.22.22" }, "dependencies": { - "@gouvfr-lasuite/cunningham-react": "4.3.0", - "@gouvfr-lasuite/ui-kit": "0.27.0", + "@gouvfr-lasuite/cunningham-react": "4.4.0", + "@gouvfr-lasuite/ui-kit": "0.28.0", "@tanstack/react-query": "5.90.10", "@tanstack/react-table": "8.21.3", "@viselect/react": "3.9.0", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index da4291981..aef42e554 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -702,10 +702,10 @@ dependencies: tslib "^2.8.0" -"@gouvfr-lasuite/cunningham-react@4.3.0": - version "4.3.0" - resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/cunningham-react/-/cunningham-react-4.3.0.tgz#a92f70e5f32d2b9cae4971642544d306e7fadfed" - integrity sha512-jGUebugMdK4LnyctS3EPrd1hKdt7oN4o22oazARuerq9JP0Yb9cmB49NsyleoFaMerF7nTJoD4eJCy0Y/9AeoQ== +"@gouvfr-lasuite/cunningham-react@4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/cunningham-react/-/cunningham-react-4.4.0.tgz#0a04d84848487b1e06bb504635f6075454d6c2b4" + integrity sha512-nWv1nQvGLIfz1cE08D0cQ1zn05+eX7PWOeenv4yAyDSb4I6pgrNMD+7zxnBlyOj9LPyvk66QClhivaPns66Gnw== dependencies: "@fontsource-variable/roboto-flex" "5.2.5" "@fontsource/material-icons-outlined" "5.2.5" @@ -742,16 +742,16 @@ resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234" integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg== -"@gouvfr-lasuite/ui-kit@0.27.0": - version "0.27.0" - resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.27.0.tgz#a8c51a6eed016e6c1dce55484b10c9ae74f751a5" - integrity sha512-6JKpbT+JGr5oJQHKkT0DiLcy/wMyuHFjmzvS++lpaslRHbPU7O3JYbkt9ilYW/r0wyNc6BRLN8ERpNmgUP3DEQ== +"@gouvfr-lasuite/ui-kit@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.28.0.tgz#6b3cc898a62b649b726ae3aad3dd446d9f29427b" + integrity sha512-HUSogjMIvVWVkzFNrNCoBLM4wY6hCMwY48UrppTvPKhiufJh/aDSSCwWXCzj9qa0YWJDn2+YIVnH2xpu6XJh7g== dependencies: "@dnd-kit/core" "6.3.1" "@dnd-kit/modifiers" "9.0.0" "@dnd-kit/sortable" "10.0.0" "@fontsource/material-icons" "5.2.5" - "@gouvfr-lasuite/cunningham-react" "4.3.0" + "@gouvfr-lasuite/cunningham-react" "4.4.0" "@gouvfr-lasuite/cunningham-tokens" "3.1.0" "@gouvfr-lasuite/integration" "1.0.2" "@types/node" "22.10.7" @@ -763,6 +763,7 @@ react-resizable-panels "2.1.7" react-stately "3.37.0" react-virtualized "9.22.6" + read-excel-file "9.3.1" "@humanfs/core@^0.19.2": version "0.19.2" @@ -5034,6 +5035,11 @@ fflate@^0.4.8: resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== +fflate@^0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc" + integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA== + figlet@1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/figlet/-/figlet-1.8.1.tgz#e8e8a07e8c16be24c31086d7d5de8a9b9cf7f0fd" @@ -5271,7 +5277,7 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.9: +graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -7256,6 +7262,15 @@ react@19.2.0: resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5" integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== +read-excel-file@9.3.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/read-excel-file/-/read-excel-file-9.3.1.tgz#8bbd360bb297106e432e5798f74c1bc66dcbefe0" + integrity sha512-yzC1vJ/yl3PGJfCDrOI6/rBagF0bRm/CK1NTNXYdomB+13mDB9SFyoRibsDXxDAFrwCANfuRqzuUWDljkkSEuQ== + dependencies: + fflate "^0.8.3" + saxen "^11.0.2" + unzipper-esm "^0.13.2" + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -7443,6 +7458,11 @@ sass@1.94.0: optionalDependencies: "@parcel/watcher" "^2.4.1" +saxen@^11.0.2: + version "11.1.1" + resolved "https://registry.yarnpkg.com/saxen/-/saxen-11.1.1.tgz#0208e438b574887ee27e70712f0aa2de1bce3714" + integrity sha512-J4BkmJFaM7VgE7pgkFGsNEcqqM3h7+Mz80vfLWFhx7uNOCOXIu6LLjQHYWNejdst3pf/3JUaBIG9+pkk1umlow== + scheduler@^0.27.0: version "0.27.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" @@ -8034,6 +8054,14 @@ unrs-resolver@^1.6.2: "@unrs/resolver-binding-win32-ia32-msvc" "1.12.2" "@unrs/resolver-binding-win32-x64-msvc" "1.12.2" +unzipper-esm@^0.13.2: + version "0.13.3" + resolved "https://registry.yarnpkg.com/unzipper-esm/-/unzipper-esm-0.13.3.tgz#731e1b28f895f174a5351cf84ea24e8adb4f155c" + integrity sha512-LUO6VZ6fCzkDbdMev0/fOhoIeVGKaOkTIOoYxVLE0SQjfvmAHK+oywl7lfhloSZIsdGJ25mJ18Mtd9CyTASjrA== + dependencies: + graceful-fs "^4.2.2" + node-int64 "^0.4.0" + update-browserslist-db@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" From ab8e0ebc3996480b67a1c4399def116771311817 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 23 Jul 2026 16:59:40 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8(frontend)=20share=20an=20item=20w?= =?UTF-8?q?ith=20contacts=20imported=20from=20a=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plug the ui-kit share modal file import onto the new batch share endpoint. The option only shows up when ALLOW_SHARE_IMPORT_FILE is enabled in the config. Errors are rendered inside the import modal instead of the global toast so the user can fix the file and retry without losing context. The e2e environment enables the flag to cover the flow end to end. --- CHANGELOG.md | 1 + env.d/development/common.e2e | 2 + .../src/features/drivers/DTOs/AccessesDTO.ts | 8 ++ .../apps/drive/src/features/drivers/Driver.ts | 2 + .../drivers/implementations/StandardDriver.ts | 10 ++ .../apps/drive/src/features/drivers/types.ts | 1 + .../modals/share/ItemShareModal.tsx | 33 ++++- .../explorer/hooks/useMutationsAccesses.ts | 17 +++ .../app-drive/assets/share-contacts.csv | 2 + .../__tests__/app-drive/share-import.spec.ts | 128 ++++++++++++++++++ 10 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/assets/share-contacts.csv create mode 100644 src/frontend/apps/e2e/__tests__/app-drive/share-import.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 78cd3e4f2..fd26814fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - ✨(frontend) show the messages widget button on the homepage - ✨(frontend) open the messages widget from the help menu - ✨(backend) add an item batch share endpoint gated by ALLOW_SHARE_IMPORT_FILE +- ✨(frontend) share an item with contacts imported from a file ### Changed diff --git a/env.d/development/common.e2e b/env.d/development/common.e2e index 78e6c0d01..87e7045fa 100644 --- a/env.d/development/common.e2e +++ b/env.d/development/common.e2e @@ -3,3 +3,5 @@ FRONTEND_HELP_MENU_CONFIG={"documentationUrl": "https://docs.numerique.gouv.fr/d WOPI_CLIENTS="collabora" # Report uploads as safe synchronously so files are ready instantly in e2e. MALWARE_DETECTION_BACKEND=lasuite.malware_detection.backends.dummy.DummyBackend +# Enable the share modal contacts file import tested by share-import.spec.ts +ALLOW_SHARE_IMPORT_FILE=True diff --git a/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts b/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts index 97790a726..3437fc06e 100644 --- a/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts +++ b/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts @@ -18,6 +18,14 @@ export type DTODeleteAccess = { accessId: string; }; +export type DTOBatchShare = { + itemId: string; + rows: { + email: string; + role: Role; + }[]; +}; + export type DTOUpdateLinkConfiguration = { itemId: string; link_reach: LinkReach; diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index 17329a109..863f493e8 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -1,5 +1,6 @@ import { ExplorerFilterModifiedValue } from "../explorer/components/filters/ExplorerFilterModified"; import { + DTOBatchShare, DTOCreateAccess, DTODeleteAccess, DTOUpdateAccess, @@ -142,6 +143,7 @@ export abstract class Driver { abstract deleteFavoriteItem(itemId: string): Promise; abstract getItemAccesses(itemId: string): Promise; abstract createAccess(data: DTOCreateAccess): Promise; + abstract batchShare(payload: DTOBatchShare): Promise; abstract updateAccess(payload: DTOUpdateAccess): Promise; abstract updateLinkConfiguration( payload: DTOUpdateLinkConfiguration, diff --git a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts index ea3b19325..ae5bb3d17 100644 --- a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts +++ b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts @@ -12,6 +12,7 @@ import { DTOUpdateInvitation, } from "../DTOs/InvitationDTO"; import { + DTOBatchShare, DTOCreateAccess, DTOUpdateLinkConfiguration, } from "../DTOs/AccessesDTO"; @@ -193,6 +194,15 @@ export class StandardDriver extends Driver { }); } + async batchShare(payload: DTOBatchShare): Promise { + await fetchAPI(`items/${payload.itemId}/batch-share/`, { + method: "POST", + body: JSON.stringify({ + rows: payload.rows, + }), + }); + } + async deleteAccess(payload: DTODeleteAccess): Promise { await fetchAPI(`items/${payload.itemId}/accesses/${payload.accessId}/`, { method: "DELETE", diff --git a/src/frontend/apps/drive/src/features/drivers/types.ts b/src/frontend/apps/drive/src/features/drivers/types.ts index 55000b24d..41f6d7a18 100644 --- a/src/frontend/apps/drive/src/features/drivers/types.ts +++ b/src/frontend/apps/drive/src/features/drivers/types.ts @@ -201,6 +201,7 @@ export interface ThemeCustomization { } export type ApiConfig = { + ALLOW_SHARE_IMPORT_FILE?: boolean; AWS_S3_UPLOAD_ACL?: string; DATA_UPLOAD_MAX_MEMORY_SIZE?: number; POSTHOG_KEY?: string; diff --git a/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx b/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx index f0597d325..b831524c9 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx @@ -9,6 +9,7 @@ import { User, } from "@/features/drivers/types"; import { + useMutationBatchShare, useMutationCreateAccess, useMutationCreateInvitation, useMutationDeleteAccess, @@ -16,6 +17,7 @@ import { useMutationUpdateAccess, useMutationUpdateInvitation, } from "@/features/explorer/hooks/useMutationsAccesses"; +import { useConfig } from "@/features/config/ConfigProvider"; import { useMutationUpdateLinkConfiguration } from "@/features/explorer/hooks/useMutations"; import { useInfiniteItemInvitations, @@ -32,7 +34,9 @@ import { } from "@gouvfr-lasuite/ui-kit"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/router"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, VariantType } from "@gouvfr-lasuite/cunningham-react"; +import { errorToString } from "@/features/api/APIError"; import { useTranslation } from "react-i18next"; import { useAuth } from "@/features/auth/Auth"; import posthog from "posthog-js"; @@ -50,6 +54,7 @@ export const ItemShareModal = ({ }: WorkspaceShareModalProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { config } = useConfig(); const { user } = useAuth(); const copyToClipboard = useClipboard(); const itemId = initialItem.originalId ?? initialItem.id; @@ -77,6 +82,8 @@ export const ItemShareModal = ({ const { mutateAsync: deleteAccess } = useMutationDeleteAccess(); const { mutateAsync: deleteInvitation } = useMutationDeleteInvitation(); const { mutateAsync: updateInvitation } = useMutationUpdateInvitation(); + const { mutateAsync: batchShare } = useMutationBatchShare(); + const [importModalChildren, setImportModalChildren] = useState(); const rolesOptions = useMemo( () => @@ -498,6 +505,30 @@ export const ItemShareModal = ({ link_role: linkRole, }); }} + allowFileImport={config.ALLOW_SHARE_IMPORT_FILE ?? false} + onImportContacts={async (rows) => { + try { + await batchShare({ + itemId, + rows: rows.map((row) => ({ + email: row.email, + role: row.role as Role, + })), + }); + posthog.capture("import_share_contacts", { + item_id: itemId, + row_count: rows.length, + }); + return true; + } catch (error) { + setImportModalChildren( + {errorToString(error)}, + ); + return false; + } + }} + importModalChildren={importModalChildren} + onImportFileChange={() => setImportModalChildren(undefined)} > {!item?.abilities.accesses_manage && } diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts index bdaa81fce..fd61a6edf 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts @@ -19,6 +19,23 @@ export const useMutationCreateAccess = () => { }); }; +export const useMutationBatchShare = () => { + const driver = getDriver(); + const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); + return useMutation({ + // Errors are displayed inside the import modal, not by the global toast + meta: { noGlobalError: true }, + mutationFn: (...payload: Parameters) => { + return driver.batchShare(...payload); + }, + onSuccess: (_, variables) => { + // A batch can create both accesses and invitations + onSuccessAccessOrInvitation(variables.itemId, false); + onSuccessAccessOrInvitation(variables.itemId, true); + }, + }); +}; + export const useMutationCreateInvitation = () => { const driver = getDriver(); const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); diff --git a/src/frontend/apps/e2e/__tests__/app-drive/assets/share-contacts.csv b/src/frontend/apps/e2e/__tests__/app-drive/assets/share-contacts.csv new file mode 100644 index 000000000..64763b881 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-drive/assets/share-contacts.csv @@ -0,0 +1,2 @@ +user@webkit.test;editor +imported@example.com;reader diff --git a/src/frontend/apps/e2e/__tests__/app-drive/share-import.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/share-import.spec.ts new file mode 100644 index 000000000..6e8f33e99 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-drive/share-import.spec.ts @@ -0,0 +1,128 @@ +import { Page, expect, test } from "@playwright/test"; +import path from "path"; +import { clearDb, login } from "./utils-common"; +import { createFolderInCurrentFolder } from "./utils-item"; +import { clickToMyFiles, navigateToFolder } from "./utils-navigate"; +import { + expectUserInMembersList, + getShareModal, + openShareModal, +} from "./utils/share-utils"; + +const CONTACTS_CSV = path.join(__dirname, "assets/share-contacts.csv"); + +const mockConfig = async (page: Page, allowShareImportFile: boolean) => { + await page.route("**/api/v1.0/config/", async (route) => { + const response = await route.fetch(); + const json = await response.json(); + json.ALLOW_SHARE_IMPORT_FILE = allowShareImportFile; + await route.fulfill({ response, json }); + }); +}; + +const getImportModal = (page: Page) => { + return page + .locator(".c__modal") + .filter({ has: page.locator(".c__share__import__modal") }); +}; + +const openImportModal = async (page: Page) => { + const shareModal = await openShareModal(page); + await shareModal.getByRole("button", { name: "Import contacts" }).click(); + await page.getByRole("menuitem", { name: "Import contacts" }).click(); + const importModal = getImportModal(page); + await expect(importModal).toBeVisible(); + return importModal; +}; + +const goToNewFolder = async (page: Page, folderName: string) => { + await page.goto("/"); + await clickToMyFiles(page); + await createFolderInCurrentFolder(page, folderName); + await navigateToFolder(page, folderName, ["My files", folderName]); +}; + +test.describe("Share modal contacts import", () => { + test("the import entry is hidden when the feature is disabled", async ({ + page, + }) => { + await clearDb(); + await mockConfig(page, false); + await login(page, "drive@example.com"); + await goToNewFolder(page, "Import disabled"); + + const shareModal = await openShareModal(page); + await expect(shareModal.getByTestId("members-list")).toBeVisible(); + await expect( + shareModal.getByRole("button", { name: "Import contacts" }), + ).toBeHidden(); + }); + + test("importing a file shares with users and invites unknown emails", async ({ + page, + request, + }) => { + await clearDb(); + // Make sure the webkit user exists so its row creates an access + // while the unknown email creates an invitation. + await request.post("http://localhost:8071/api/v1.0/e2e/user-auth/", { + data: { email: "user@webkit.test" }, + }); + await mockConfig(page, true); + await login(page, "drive@example.com"); + await goToNewFolder(page, "Import contacts folder"); + + const importModal = await openImportModal(page); + await importModal.locator('input[type="file"]').setInputFiles(CONTACTS_CSV); + await expect( + importModal.getByText("2 rows ready to be imported."), + ).toBeVisible(); + await importModal.getByRole("button", { name: "Import", exact: true }).click(); + + await expect(importModal).toBeHidden(); + await expectUserInMembersList(page, "user@webkit.test", "Editor"); + const shareModal = await getShareModal(page); + await expect(shareModal.getByTestId("invitations-list")).toContainText( + "imported@example.com", + ); + }); + + test("a failed import shows the backend error inside the import modal", async ({ + page, + }) => { + await clearDb(); + await mockConfig(page, true); + await page.route("**/batch-share/", async (route) => { + await route.fulfill({ + status: 400, + json: { + type: "validation_error", + errors: [ + { + attr: "rows", + code: "invalid", + detail: "This import is not valid.", + }, + ], + }, + }); + }); + await login(page, "drive@example.com"); + await goToNewFolder(page, "Import error folder"); + + const importModal = await openImportModal(page); + await importModal.locator('input[type="file"]').setInputFiles(CONTACTS_CSV); + await importModal.getByRole("button", { name: "Import", exact: true }).click(); + + await expect( + importModal.getByText("This import is not valid."), + ).toBeVisible(); + await expect(importModal).toBeVisible(); + + // Removing the selected file clears the previous error + await importModal.getByText("Delete", { exact: true }).first().click(); + await expect( + importModal.getByText("This import is not valid."), + ).toBeHidden(); + }); +}); From c45b0943c5aed851cfb615ad99c7511055ded0e4 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Tue, 28 Jul 2026 11:43:12 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=85(frontend)=20mock=20saxen=20in=20j?= =?UTF-8?q?est=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui-kit 0.28.0 pulls in read-excel-file, whose CommonJS build requires saxen, an ESM-only package that jest cannot parse. No unit test parses spreadsheets, so a stub parser is enough, following the pretty-bytes precedent. --- src/frontend/apps/drive/__mocks__/saxen.js | 10 ++++++++++ src/frontend/apps/drive/jest.config.ts | 1 + 2 files changed, 11 insertions(+) create mode 100644 src/frontend/apps/drive/__mocks__/saxen.js diff --git a/src/frontend/apps/drive/__mocks__/saxen.js b/src/frontend/apps/drive/__mocks__/saxen.js new file mode 100644 index 000000000..36a42423f --- /dev/null +++ b/src/frontend/apps/drive/__mocks__/saxen.js @@ -0,0 +1,10 @@ +// saxen is ESM-only, which Jest cannot parse from read-excel-file's CJS +// build (pulled in by ui-kit). No unit test parses spreadsheets, so a +// stub Parser is enough. +module.exports = { + Parser: class Parser { + on() {} + parse() {} + stop() {} + }, +}; diff --git a/src/frontend/apps/drive/jest.config.ts b/src/frontend/apps/drive/jest.config.ts index 29a192325..fb302a0df 100644 --- a/src/frontend/apps/drive/jest.config.ts +++ b/src/frontend/apps/drive/jest.config.ts @@ -12,6 +12,7 @@ const config: Config = { "\\.(css|less|scss|sass|svg|png|jpg|jpeg|gif)$": "/__mocks__/fileMock.js", "^pretty-bytes$": "/__mocks__/pretty-bytes.js", + "^saxen$": "/__mocks__/saxen.js", // Then handle path aliases ...pathsToModuleNameMapper(tsconfig.compilerOptions.paths || {}, { prefix: "/", From f239b58e4d536e44bbda3ca04957287104b8bfc2 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Tue, 28 Jul 2026 12:00:32 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=85(frontend)=20adapt=20share=20e2e?= =?UTF-8?q?=20helper=20to=20the=20ui-kit=200.28=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui-kit 0.28.0 replaces the share modal plain quick search input with an invite field: selected users become chips next to a role dropdown and a dedicated share button. The helper still targeted the old combobox label and page-level share button, breaking every spec sharing an item. --- .../__tests__/app-drive/utils/share-utils.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/frontend/apps/e2e/__tests__/app-drive/utils/share-utils.ts b/src/frontend/apps/e2e/__tests__/app-drive/utils/share-utils.ts index 2ee6fa633..2fc4fb721 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/utils/share-utils.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/utils/share-utils.ts @@ -159,19 +159,16 @@ export const shareCurrentItemWithWebkitUser = async ( ) => { await clickOnBreadcrumbButtonAction(page, "Share"); const shareModal = await expectShareModal(page); - await expect( - shareModal.getByRole("combobox", { name: "Quick search input" }), - ).toBeVisible(); - await shareModal - .getByRole("combobox", { name: "Quick search input" }) - .click(); - await shareModal - .getByRole("combobox", { name: "Quick search input" }) - .fill("webkit"); + const searchInput = shareModal.getByRole("combobox", { + name: "Search for a user to invite", + }); + await expect(searchInput).toBeVisible(); + await searchInput.click(); + await searchInput.fill("webkit"); const userSearchItem = await getUserSearchResult(page, "user@webkit.test"); await expect(userSearchItem).toBeVisible(); await userSearchItem.click(); await selectRoleUser(page, userRole); - await page.getByRole("button", { name: "Share" }).click(); + await shareModal.getByTestId("share-invite-button").click(); await expectUserInMembersList(page, "user@webkit.test", userRole); };