From 6d4958411482e78879f696564cdc6c7dfd139fcb Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 18 Jun 2025 15:13:48 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8(back)=20document=20as=20for=20a?= =?UTF-8?q?ccess=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We introduce a new model for user wanted to access a document or upgrade their role if they already have access. The viewsets does not implement PUT and PATCH, we don't need it for now. --- src/backend/core/api/serializers.py | 23 + src/backend/core/api/viewsets.py | 65 +++ src/backend/core/factories.py | 11 + ...lter_user_language_documentaskforaccess.py | 89 ++++ src/backend/core/models.py | 59 +++ .../test_api_documents_ask_for_access.py | 436 ++++++++++++++++++ src/backend/core/urls.py | 6 + 7 files changed, 689 insertions(+) create mode 100644 src/backend/core/migrations/0022_alter_user_language_documentaskforaccess.py create mode 100644 src/backend/core/tests/documents/test_api_documents_ask_for_access.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index e86288bb34..67fcbaea0a 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -664,6 +664,29 @@ def validate_role(self, role): return role +class DocumentAskForAccessCreateSerializer(serializers.Serializer): + """Serializer for creating a document ask for access.""" + + role = serializers.ChoiceField(choices=models.RoleChoices.choices, required=False, default=models.RoleChoices.READER) + + +class DocumentAskForAccessSerializer(serializers.ModelSerializer): + """Serializer for document ask for access model""" + + abilities = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.DocumentAskForAccess + fields = ["id", "document", "user", "role", "created_at", "abilities"] + read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"] + + def get_abilities(self, invitation) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return invitation.get_abilities(request.user) + return {} + class VersionFilterSerializer(serializers.Serializer): """Validate version filters applied to the list endpoint.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 2250c91aa4..2e147da68b 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1772,6 +1772,71 @@ def perform_create(self, serializer): ) +class DocumentAskForAccessViewSet( + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """API ViewSet for asking for access to a document.""" + + lookup_field = "id" + pagination_class = Pagination + permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] + queryset = models.DocumentAskForAccess.objects.all() + serializer_class = serializers.DocumentAskForAccessSerializer + _document = None + + def get_document_or_404(self): + """Get the document related to the viewset or raise a 404 error.""" + if self._document is None: + try: + self._document = models.Document.objects.get( + pk=self.kwargs["resource_id"] + ) + except models.Document.DoesNotExist as e: + raise drf.exceptions.NotFound("Document not found.") from e + return self._document + + def get_queryset(self): + """Return the queryset according to the action.""" + document = self.get_document_or_404() + + queryset = super().get_queryset() + queryset = queryset.filter(document=document) + + roles = set(document.get_roles(self.request.user)) + is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES))) + # self.is_current_user_owner_or_admin = is_owner_or_admin + if not is_owner_or_admin: + queryset = queryset.filter(user=self.request.user) + + return queryset + + def create(self, request, *args, **kwargs): + """Create a document ask for access resource.""" + document = self.get_document_or_404() + + serializer = serializers.DocumentAskForAccessCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + queryset = self.get_queryset() + + if queryset.filter(user=request.user).exists(): + return drf.response.Response( + {"detail": "You already ask to access to this document."}, + status=drf.status.HTTP_400_BAD_REQUEST, + ) + + models.DocumentAskForAccess.objects.create( + document=document, + user=request.user, + role=serializer.validated_data["role"], + ) + + return drf.response.Response(status=drf.status.HTTP_201_CREATED) + + class ConfigView(drf.views.APIView): """API ViewSet for sharing some public settings.""" diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 15245c391a..c82776f3e5 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -183,6 +183,17 @@ class Meta: role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) +class DocumentAskForAccessFactory(factory.django.DjangoModelFactory): + """Create fake document ask for access for testing.""" + + class Meta: + model = models.DocumentAskForAccess + + document = factory.SubFactory(DocumentFactory) + user = factory.SubFactory(UserFactory) + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) + + class TemplateFactory(factory.django.DjangoModelFactory): """A factory to create templates""" diff --git a/src/backend/core/migrations/0022_alter_user_language_documentaskforaccess.py b/src/backend/core/migrations/0022_alter_user_language_documentaskforaccess.py new file mode 100644 index 0000000000..62e314ebb6 --- /dev/null +++ b/src/backend/core/migrations/0022_alter_user_language_documentaskforaccess.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2.3 on 2025-06-18 10:02 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0021_activate_unaccent_extension"), + ] + + operations = [ + migrations.CreateModel( + name="DocumentAskForAccess", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("reader", "Reader"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ask_for_accesses", + to="core.document", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ask_for_accesses", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Document ask for access", + "verbose_name_plural": "Document ask for accesses", + "db_table": "impress_document_ask_for_access", + "constraints": [ + models.UniqueConstraint( + fields=("user", "document"), + name="unique_document_ask_for_access_user", + violation_error_message="This user has already asked for access to this document.", + ) + ], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index a2599edfb2..ca9f4b193d 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1149,6 +1149,65 @@ def get_abilities(self, user): } +class DocumentAskForAccess(BaseModel): + """Relation model to ask for access to a document.""" + + document = models.ForeignKey( + Document, on_delete=models.CASCADE, related_name="ask_for_accesses" + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="ask_for_accesses" + ) + + role = models.CharField( + max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER + ) + + class Meta: + db_table = "impress_document_ask_for_access" + verbose_name = _("Document ask for access") + verbose_name_plural = _("Document ask for accesses") + constraints = [ + models.UniqueConstraint( + fields=["user", "document"], + name="unique_document_ask_for_access_user", + violation_error_message=_( + "This user has already asked for access to this document." + ), + ), + ] + + def __str__(self): + return f"{self.user!s} asked for access to document {self.document!s}" + + def get_abilities(self, user): + """Compute and return abilities for a given user.""" + roles = [] + + if user.is_authenticated: + teams = user.teams + try: + roles = self.user_roles or [] + except AttributeError: + try: + roles = self.document.accesses.filter( + models.Q(user=user) | models.Q(team__in=teams), + ).values_list("role", flat=True) + except (self._meta.model.DoesNotExist, IndexError): + roles = [] + + is_admin_or_owner = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + + return { + "destroy": is_admin_or_owner, + "update": is_admin_or_owner, + "partial_update": is_admin_or_owner, + "retrieve": is_admin_or_owner, + } + + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" diff --git a/src/backend/core/tests/documents/test_api_documents_ask_for_access.py b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py new file mode 100644 index 0000000000..7dca914100 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py @@ -0,0 +1,436 @@ +"""Test API for document ask for access.""" + +import uuid + +import pytest +from rest_framework.test import APIClient + +from core.factories import DocumentAskForAccessFactory, DocumentFactory, UserFactory +from core.models import DocumentAskForAccess, RoleChoices + +pytestmark = pytest.mark.django_db + +## Create + + +def test_api_documents_ask_for_access_create_anonymous(): + """Anonymous users should not be able to create a document ask for access.""" + document = DocumentFactory() + + client = APIClient() + response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/") + + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_create_invalid_document_id(): + """Invalid document ID should return a 404 error.""" + user = UserFactory() + + client = APIClient() + client.force_login(user) + response = client.post(f"/api/v1.0/documents/{uuid.uuid4()}/ask-for-access/") + + assert response.status_code == 404 + + +def test_api_documents_ask_for_access_create_authenticated(): + """Authenticated users should be able to create a document ask for access.""" + document = DocumentFactory() + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 201 + + assert DocumentAskForAccess.objects.filter( + document=document, + user=user, + role=RoleChoices.READER, + ).exists() + + +def test_api_documents_ask_for_access_create_authenticated_specific_role(): + """ + Authenticated users should be able to create a document ask for access with a specific role. + """ + document = DocumentFactory() + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/", + data={"role": RoleChoices.EDITOR}, + ) + assert response.status_code == 201 + + assert DocumentAskForAccess.objects.filter( + document=document, + user=user, + role=RoleChoices.EDITOR, + ).exists() + + +def test_api_documents_ask_for_access_create_authenticated_already_has_access(): + """Authenticated users with existing access can ask for access with a different role.""" + user = UserFactory() + document = DocumentFactory(users=[(user, RoleChoices.READER)]) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/", + data={"role": RoleChoices.EDITOR}, + ) + assert response.status_code == 201 + + assert DocumentAskForAccess.objects.filter( + document=document, + user=user, + role=RoleChoices.EDITOR, + ).exists() + + +def test_api_documents_ask_for_access_create_authenticated_already_has_ask_for_access(): + """ + Authenticated users with existing ask for access can not ask for a new access on this document. + """ + user = UserFactory() + document = DocumentFactory(users=[(user, RoleChoices.READER)]) + DocumentAskForAccessFactory(document=document, user=user, role=RoleChoices.READER) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/", + data={"role": RoleChoices.EDITOR}, + ) + assert response.status_code == 400 + assert response.json() == {"detail": "You already ask to access to this document."} + + +## List + + +def test_api_documents_ask_for_access_list_anonymous(): + """Anonymous users should not be able to list document ask for access.""" + document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_list_authenticated(): + """Authenticated users should be able to list document ask for access.""" + document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +def test_api_documents_ask_for_access_list_authenticated_own_request(): + """Authenticated users should be able to list their own document ask for access.""" + document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + user = UserFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, user=user, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": str(document_ask_for_access.id), + "document": str(document.id), + "user": str(user.id), + "role": RoleChoices.READER, + "created_at": document_ask_for_access.created_at.isoformat().replace( + "+00:00", "Z" + ), + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "retrieve": False, + }, + } + ], + } + + +def test_api_documents_ask_for_access_list_authenticated_other_document(): + """Authenticated users should not be able to list document ask for access of other documents.""" + document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + + other_document = DocumentFactory() + DocumentAskForAccessFactory.create_batch( + 3, document=other_document, role=RoleChoices.READER + ) + + response = client.get(f"/api/v1.0/documents/{other_document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR]) +def test_api_documents_ask_for_access_list_non_owner_or_admin(role): + """Non owner or admin users should not be able to list document ask for access.""" + + user = UserFactory() + + document = DocumentFactory(users=[(user, role)]) + DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +def test_api_documents_ask_for_access_list_owner_or_admin(role): + """Owner or admin users should be able to list document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory.create_batch( + 3, document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "id": str(document_ask_for_access.id), + "document": str(document.id), + "user": str(document_ask_for_access.user.id), + "role": RoleChoices.READER, + "created_at": document_ask_for_access.created_at.isoformat().replace( + "+00:00", "Z" + ), + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + }, + } + for document_ask_for_access in document_ask_for_access + ], + } + + +## Retrieve + + +def test_api_documents_ask_for_access_retrieve_anonymous(): + """Anonymous users should not be able to retrieve document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_retrieve_authenticated(): + """Authenticated users should not be able to retrieve document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + + response = client.get( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR]) +def test_api_documents_ask_for_access_retrieve_authenticated_non_owner_or_admin(role): + """Non owner or admin users should not be able to retrieve document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +def test_api_documents_ask_for_access_retrieve_owner_or_admin(role): + """Owner or admin users should be able to retrieve document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(document_ask_for_access.id), + "document": str(document.id), + "user": str(document_ask_for_access.user.id), + "role": RoleChoices.READER, + "created_at": document_ask_for_access.created_at.isoformat().replace( + "+00:00", "Z" + ), + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "retrieve": True, + }, + } + + +## Delete + + +def test_api_documents_ask_for_access_delete_anonymous(): + """Anonymous users should not be able to delete document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_delete_authenticated(): + """Authenticated users should not be able to delete document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR]) +def test_api_documents_ask_for_access_delete_authenticated_non_owner_or_admin(role): + """Non owner or admin users should not be able to delete document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +def test_api_documents_ask_for_access_delete_owner_or_admin(role): + """Owner or admin users should be able to delete document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" + ) + assert response.status_code == 204 + assert not DocumentAskForAccess.objects.filter( + id=document_ask_for_access.id + ).exists() diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 0544189547..2ad8b00395 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -27,6 +27,12 @@ basename="invitations", ) +document_related_router.register( + "ask-for-access", + viewsets.DocumentAskForAccessViewSet, + basename="ask_for_access", +) + # - Routes nested under a template template_related_router = DefaultRouter() From 39937cbee26ad74da760b95e52d50f78bf9561e7 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 18 Jun 2025 15:50:12 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8(back)=20accept=20for=20a=20owne?= =?UTF-8?q?r=20the=20request=20to=20access=20a=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the action accepting a request to access a document. It is possible to override the role from the request and also update an existing DocumentAccess --- src/backend/core/api/serializers.py | 17 +- src/backend/core/api/viewsets.py | 11 ++ src/backend/core/models.py | 14 ++ .../test_api_documents_ask_for_access.py | 181 +++++++++++++++++- 4 files changed, 219 insertions(+), 4 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 67fcbaea0a..d22ccf5116 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -664,10 +664,22 @@ def validate_role(self, role): return role +class RoleSerializer(serializers.Serializer): + """Serializer validating role choices.""" + + role = serializers.ChoiceField( + choices=models.RoleChoices.choices, required=False, allow_null=True + ) + + class DocumentAskForAccessCreateSerializer(serializers.Serializer): """Serializer for creating a document ask for access.""" - role = serializers.ChoiceField(choices=models.RoleChoices.choices, required=False, default=models.RoleChoices.READER) + role = serializers.ChoiceField( + choices=models.RoleChoices.choices, + required=False, + default=models.RoleChoices.READER, + ) class DocumentAskForAccessSerializer(serializers.ModelSerializer): @@ -686,7 +698,8 @@ def get_abilities(self, invitation) -> dict: if request: return invitation.get_abilities(request.user) return {} - + + class VersionFilterSerializer(serializers.Serializer): """Validate version filters applied to the list endpoint.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 2e147da68b..5fd9b57c63 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1836,6 +1836,17 @@ def create(self, request, *args, **kwargs): return drf.response.Response(status=drf.status.HTTP_201_CREATED) + @drf.decorators.action(detail=True, methods=["post"]) + def accept(self, request, *args, **kwargs): + """Accept a document ask for access resource.""" + document_ask_for_access = self.get_object() + + serializer = serializers.RoleSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + document_ask_for_access.accept(role=serializer.validated_data.get("role")) + return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT) + class ConfigView(drf.views.APIView): """API ViewSet for sharing some public settings.""" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index ca9f4b193d..f137802d0a 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1205,8 +1205,22 @@ def get_abilities(self, user): "update": is_admin_or_owner, "partial_update": is_admin_or_owner, "retrieve": is_admin_or_owner, + "accept": is_admin_or_owner, } + def accept(self, role=None): + """Accept a document ask for access resource.""" + if role is None: + role = self.role + + DocumentAccess.objects.update_or_create( + document=self.document, + user=self.user, + defaults={"role": role}, + create_defaults={"role": role}, + ) + self.delete() + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" diff --git a/src/backend/core/tests/documents/test_api_documents_ask_for_access.py b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py index 7dca914100..3759662d61 100644 --- a/src/backend/core/tests/documents/test_api_documents_ask_for_access.py +++ b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py @@ -5,8 +5,13 @@ import pytest from rest_framework.test import APIClient -from core.factories import DocumentAskForAccessFactory, DocumentFactory, UserFactory -from core.models import DocumentAskForAccess, RoleChoices +from core.factories import ( + DocumentAskForAccessFactory, + DocumentFactory, + UserDocumentAccessFactory, + UserFactory, +) +from core.models import DocumentAccess, DocumentAskForAccess, RoleChoices pytestmark = pytest.mark.django_db @@ -182,6 +187,7 @@ def test_api_documents_ask_for_access_list_authenticated_own_request(): "+00:00", "Z" ), "abilities": { + "accept": False, "destroy": False, "update": False, "partial_update": False, @@ -269,6 +275,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role): "+00:00", "Z" ), "abilities": { + "accept": True, "destroy": True, "update": True, "partial_update": True, @@ -356,6 +363,7 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role): "+00:00", "Z" ), "abilities": { + "accept": True, "destroy": True, "update": True, "partial_update": True, @@ -434,3 +442,172 @@ def test_api_documents_ask_for_access_delete_owner_or_admin(role): assert not DocumentAskForAccess.objects.filter( id=document_ask_for_access.id ).exists() + + +## Accept + + +def test_api_documents_ask_for_access_accept_anonymous(): + """Anonymous users should not be able to accept document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/" + ) + assert response.status_code == 401 + + +def test_api_documents_ask_for_access_accept_authenticated(): + """Authenticated users should not be able to accept document ask for access.""" + document = DocumentFactory() + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(UserFactory()) + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR]) +def test_api_documents_ask_for_access_accept_authenticated_non_owner_or_admin(role): + """Non owner or admin users should not be able to accept document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/" + ) + assert response.status_code == 404 + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +def test_api_documents_ask_for_access_accept_owner_or_admin(role): + """Owner or admin users should be able to accept document ask for access.""" + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/" + ) + assert response.status_code == 204 + + assert not DocumentAskForAccess.objects.filter( + id=document_ask_for_access.id + ).exists() + assert DocumentAccess.objects.filter( + document=document, user=document_ask_for_access.user, role=RoleChoices.READER + ).exists() + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +def test_api_documents_ask_for_access_accept_authenticated_specific_role(role): + """ + Owner or admin users should be able to accept document ask for access with a specific role. + """ + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, role=RoleChoices.READER + ) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/", + data={"role": RoleChoices.EDITOR}, + ) + assert response.status_code == 204 + + assert not DocumentAskForAccess.objects.filter( + id=document_ask_for_access.id + ).exists() + assert DocumentAccess.objects.filter( + document=document, user=document_ask_for_access.user, role=RoleChoices.EDITOR + ).exists() + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access( + role, +): + """ + Owner or admin users should be able to accept document ask for access and update the access. + """ + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_access = UserDocumentAccessFactory( + document=document, role=RoleChoices.READER + ) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, user=document_access.user, role=RoleChoices.EDITOR + ) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/", + data={"role": RoleChoices.EDITOR}, + ) + assert response.status_code == 204 + + assert not DocumentAskForAccess.objects.filter( + id=document_ask_for_access.id + ).exists() + document_access.refresh_from_db() + assert document_access.role == RoleChoices.EDITOR + + +@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN]) +# pylint: disable=line-too-long +def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access_with_specific_role( + role, +): + """ + Owner or admin users should be able to accept document ask for access and update the access + with a specific role. + """ + user = UserFactory() + document = DocumentFactory(users=[(user, role)]) + document_access = UserDocumentAccessFactory( + document=document, role=RoleChoices.READER + ) + document_ask_for_access = DocumentAskForAccessFactory( + document=document, user=document_access.user, role=RoleChoices.EDITOR + ) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/", + data={"role": RoleChoices.ADMIN}, + ) + assert response.status_code == 204 + + assert not DocumentAskForAccess.objects.filter( + id=document_ask_for_access.id + ).exists() + document_access.refresh_from_db() + assert document_access.role == RoleChoices.ADMIN From 6bf48b936999dbeefc74911d5a87001612ed151b Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 21 May 2025 16:19:34 +0200 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20prevent=20?= =?UTF-8?q?authentication=20retry=20on=20401=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop retry attempts when receiving 401 Unauthorized from /me endpoint since this clearly indicates authentication status. The original purpose of the /me call is simply to determine if user is authenticated, and a 401 provides sufficient information. Prevents unnecessary network requests caused by React Query's automatic retry behavior when re-raising exceptions, which was hiding the 401 status. Improves performance and reduces server load during authentication failures. --- .../apps/impress/src/core/AppProvider.tsx | 4 +++- .../src/features/auth/api/useAuthQuery.tsx | 21 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/frontend/apps/impress/src/core/AppProvider.tsx b/src/frontend/apps/impress/src/core/AppProvider.tsx index 03ce5097d8..9f18e1142b 100644 --- a/src/frontend/apps/impress/src/core/AppProvider.tsx +++ b/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -9,6 +9,8 @@ import { useResponsiveStore } from '@/stores/'; import { ConfigProvider } from './config/'; +export const DEFAULT_QUERY_RETRY = 1; + /** * QueryClient: * - defaultOptions: @@ -19,7 +21,7 @@ import { ConfigProvider } from './config/'; const defaultOptions = { queries: { staleTime: 1000 * 60 * 3, - retry: 1, + retry: DEFAULT_QUERY_RETRY, }, }; const queryClient = new QueryClient({ diff --git a/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx b/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx index 026beec9f9..0b3fe070c1 100644 --- a/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx +++ b/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx @@ -1,9 +1,12 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; +import { DEFAULT_QUERY_RETRY } from '@/core'; import { User } from './types'; +type UserResponse = User | null; + /** * Asynchronously retrieves the current user's data from the API. * This function is called during frontend initialization to check @@ -14,8 +17,13 @@ import { User } from './types'; * @throws {Error} Throws an error if the API request fails. * @returns {Promise} A promise that resolves to the user data. */ -export const getMe = async (): Promise => { +export const getMe = async (): Promise => { const response = await fetchAPI(`users/me/`); + + if (response.status === 401) { + return null; + } + if (!response.ok) { throw new APIError( `Couldn't fetch user data: ${response.statusText}`, @@ -28,12 +36,19 @@ export const getMe = async (): Promise => { export const KEY_AUTH = 'auth'; export function useAuthQuery( - queryConfig?: UseQueryOptions, + queryConfig?: UseQueryOptions, ) { - return useQuery({ + return useQuery({ queryKey: [KEY_AUTH], queryFn: getMe, staleTime: 1000 * 60 * 15, // 15 minutes + retry: (failureCount, error) => { + // we assume that a 401 means the user is not logged in + if (error.status == 401) { + return false; + } + return failureCount < DEFAULT_QUERY_RETRY; + }, ...queryConfig, }); } From 085232d894c031eb0f1c9dfe82ca195cb8329eba Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 22 May 2025 00:27:42 +0200 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20optimize?= =?UTF-8?q?=20document=20fetch=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce unnecessary fetch requests when retrieving documents with permission or authentication issues. Previous implementation was triggering multiple document requests despite having sufficient error information from initial attempt to determine appropriate user redirection. Additionally, fix issue where resetting the auth cache was triggering redundant authentication verification requests. The responsibility for checking auth status should belong to the 401 page component on mount, rather than being triggered by cache resets during error handling. Known limitations: - Not waiting for async function completion makes code harder to maintain - Added loading spinner as temporary solution to prevent UI flicker - Future improvement should implement consistent error-based redirects rather than rendering error messages directly on document page --- .../impress/src/pages/docs/[id]/index.tsx | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 854187ceb0..da4c10ba41 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Icon, TextErrors } from '@/components'; +import { DEFAULT_QUERY_RETRY } from '@/core'; import { DocEditor } from '@/docs/doc-editor'; import { Doc, @@ -14,7 +15,7 @@ import { useDoc, useDocStore, } from '@/docs/doc-management/'; -import { KEY_AUTH, setAuthUrl } from '@/features/auth'; +import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth'; import { MainLayout } from '@/layouts'; import { useBroadcastStore } from '@/stores'; import { NextPageWithLayout } from '@/types/next'; @@ -56,6 +57,14 @@ const DocPage = ({ id }: DocProps) => { { staleTime: 0, queryKey: [KEY_DOC, { id }], + retryDelay: 1000, + retry: (failureCount, error) => { + if (error.status == 403 || error.status == 401 || error.status == 404) { + return false; + } else { + return failureCount < DEFAULT_QUERY_RETRY; + } + }, }, ); @@ -66,6 +75,7 @@ const DocPage = ({ id }: DocProps) => { const { replace } = useRouter(); useCollaboration(doc?.id, doc?.content); const { t } = useTranslation(); + const { authenticated } = useAuth(); useEffect(() => { if (!docQuery || isFetching) { @@ -93,23 +103,24 @@ const DocPage = ({ id }: DocProps) => { }, [addTask, doc?.id, queryClient]); if (isError && error) { - if (error.status === 403) { - void replace(`/403`); - return null; - } - - if (error.status === 404) { - void replace(`/404`); - return null; - } - - if (error.status === 401) { - void queryClient.resetQueries({ - queryKey: [KEY_AUTH], - }); - setAuthUrl(); - void replace(`/401`); - return null; + if ([403, 404, 401].includes(error.status)) { + if (error.status === 401) { + if (authenticated) { + queryClient.setQueryData([KEY_AUTH], { + user: null, + authenticated: false, + }); + } + setAuthUrl(); + } + + void replace(`/${error.status}`); + + return ( + + + + ); } return ( From 469581c814e73d6ac463e3a5b5ace68ba583f298 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 19 Jun 2025 22:08:18 +0200 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=A8(frontend)=20integrate=20doc=20a?= =?UTF-8?q?ccess=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user is redirected on the 403 page, they can now request access to the document. --- src/frontend/apps/impress/cunningham.ts | 10 ++ .../src/cunningham/cunningham-tokens.css | 5 +- .../src/cunningham/cunningham-tokens.ts | 3 +- .../features/docs/doc-management/types.tsx | 15 ++ .../doc-share/api/useDocAccessRequest.tsx | 102 ++++++++++++++ .../apps/impress/src/pages/docs/[id]/403.tsx | 132 ++++++++++++++++++ .../impress/src/pages/docs/[id]/index.tsx | 6 +- 7 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx create mode 100644 src/frontend/apps/impress/src/pages/docs/[id]/403.tsx diff --git a/src/frontend/apps/impress/cunningham.ts b/src/frontend/apps/impress/cunningham.ts index 1b6481ab21..8380e15607 100644 --- a/src/frontend/apps/impress/cunningham.ts +++ b/src/frontend/apps/impress/cunningham.ts @@ -65,6 +65,16 @@ tokens.themes.default.components = { 'png-light': '/assets/favicon-light.png', 'png-dark': '/assets/favicon-dark.png', }, + button: { + ...tokens.themes.default.components.button, + primary: { + ...tokens.themes.default.components.button.primary, + ...{ + 'background--disabled': 'var(--c--theme--colors--greyscale-100)', + }, + disabled: 'var(--c--theme--colors--greyscale-400)', + }, + }, }, }; diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css index 2fdb932610..ca53bbae9e 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css +++ b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css @@ -218,7 +218,10 @@ --c--components--button--primary--color-active: #fff; --c--components--button--primary--color-focus-visible: #fff; --c--components--button--primary--disabled: var( - --c--theme--colors--greyscale-500 + --c--theme--colors--greyscale-400 + ); + --c--components--button--primary--background--disabled: var( + --c--theme--colors--greyscale-100 ); --c--components--button--primary-text--background--color: var( --c--theme--colors--primary-text diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts index a547d65970..d7395f0571 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts +++ b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts @@ -229,7 +229,8 @@ export const tokens = { 'color-hover': '#fff', 'color-active': '#fff', 'color-focus-visible': '#fff', - disabled: '#7C7C7C', + disabled: 'var(--c--theme--colors--greyscale-400)', + 'background--disabled': 'var(--c--theme--colors--greyscale-100)', }, 'primary-text': { 'background--color': '#000091', diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index e57dc6e145..2cc2b6c177 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -76,3 +76,18 @@ export enum DocDefaultFilter { MY_DOCS = 'my_docs', SHARED_WITH_ME = 'shared_with_me', } + +export interface AccessRequest { + id: string; + document: string; + user: User; + role: Role; + created_at: string; + abilities: { + destroy: boolean; + update: boolean; + partial_update: boolean; + retrieve: boolean; + accept: boolean; + }; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx new file mode 100644 index 0000000000..062edba67b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx @@ -0,0 +1,102 @@ +import { + UseMutationOptions, + UseQueryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; +import { AccessRequest, Doc, Role } from '@/docs/doc-management'; + +import { OptionType } from '../types'; + +interface CreateDocAccessRequestParams { + docId: Doc['id']; + role?: Role; +} + +export const createDocAccessRequest = async ({ + docId, + role, +}: CreateDocAccessRequestParams): Promise => { + const response = await fetchAPI(`documents/${docId}/ask-for-access/`, { + method: 'POST', + body: JSON.stringify({ + role, + }), + }); + + if (!response.ok) { + throw new APIError( + `Failed to create a request to access to the doc.`, + await errorCauses(response, { + type: OptionType.NEW_MEMBER, + }), + ); + } + + return null; +}; + +type UseCreateDocAccessRequestOptions = UseMutationOptions< + null, + APIError, + CreateDocAccessRequestParams +>; + +export function useCreateDocAccessRequest( + options?: UseCreateDocAccessRequestOptions, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createDocAccessRequest, + ...options, + onSuccess: (data, variables, context) => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS], + }); + + void options?.onSuccess?.(data, variables, context); + }, + }); +} + +type AccessRequestResponse = APIList; + +interface GetDocAccessRequestsParams { + docId: Doc['id']; +} + +export const getDocAccessRequests = async ({ + docId, +}: GetDocAccessRequestsParams): Promise => { + const response = await fetchAPI(`documents/${docId}/ask-for-access/`); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc access requests', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_ACCESS_REQUESTS = 'docs-access-requests'; + +export function useDocAccessRequests( + params: GetDocAccessRequestsParams, + queryConfig?: UseQueryOptions< + AccessRequestResponse, + APIError, + AccessRequestResponse + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS, params], + queryFn: () => getDocAccessRequests(params), + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx new file mode 100644 index 0000000000..1aa743bf87 --- /dev/null +++ b/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx @@ -0,0 +1,132 @@ +import { + Button, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import Head from 'next/head'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import img403 from '@/assets/icons/icon-403.png'; +import { Box, Icon, StyledLink, Text } from '@/components'; +import { + useCreateDocAccessRequest, + useDocAccessRequests, +} from '@/features/docs/doc-share/api/useDocAccessRequest'; +import { MainLayout } from '@/layouts'; +import { NextPageWithLayout } from '@/types/next'; + +const StyledButton = styled(Button)` + width: fit-content; +`; + +export function DocLayout() { + const { + query: { id }, + } = useRouter(); + + if (typeof id !== 'string') { + return null; + } + + return ( + <> + + + + + + + + + ); +} + +interface DocProps { + id: string; +} + +const DocPage403 = ({ id }: DocProps) => { + const { t } = useTranslation(); + const { data: requests } = useDocAccessRequests({ docId: id }); + const { toast } = useToastProvider(); + const { mutate: createRequest } = useCreateDocAccessRequest({ + onSuccess: () => { + toast(t('Access request sent successfully.'), VariantType.SUCCESS, { + duration: 3000, + }); + }, + }); + + const hasRequested = !!requests?.results.find( + (request) => request.document === id, + ); + + return ( + <> + + + {t('Access Denied - Error 403')} - {t('Docs')} + + + + + {t('Image + + + + {hasRequested + ? t('Your access request for this document is pending.') + : t('Insufficient access rights to view the document.')} + + + + + } + color="tertiary" + > + {t('Home')} + + + + + + + + ); +}; + +const Page: NextPageWithLayout = () => { + return null; +}; + +Page.getLayout = function getLayout() { + return ; +}; + +export default Page; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index da4c10ba41..278f103737 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -104,6 +104,8 @@ const DocPage = ({ id }: DocProps) => { if (isError && error) { if ([403, 404, 401].includes(error.status)) { + let replacePath = `/${error.status}`; + if (error.status === 401) { if (authenticated) { queryClient.setQueryData([KEY_AUTH], { @@ -112,9 +114,11 @@ const DocPage = ({ id }: DocProps) => { }); } setAuthUrl(); + } else if (error.status === 403) { + replacePath = `/docs/${id}/403`; } - void replace(`/${error.status}`); + void replace(replacePath); return ( From 530ee8be2581f26fea30d04c53019f7435f007c0 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 20 Jun 2025 15:24:46 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=A8(backend)=20send=20email=20to=20?= =?UTF-8?q?admins=20when=20user=20ask=20for=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user requests access to a document, an email is sent to the admins and owners of the document. --- src/backend/core/api/viewsets.py | 15 +++++++- src/backend/core/models.py | 36 +++++++++++++++++-- .../mjml/{invitation.mjml => template.mjml} | 0 3 files changed, 48 insertions(+), 3 deletions(-) rename src/mail/mjml/{invitation.mjml => template.mjml} (100%) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5fd9b57c63..1506839a30 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1828,12 +1828,25 @@ def create(self, request, *args, **kwargs): status=drf.status.HTTP_400_BAD_REQUEST, ) - models.DocumentAskForAccess.objects.create( + ask_for_access = models.DocumentAskForAccess.objects.create( document=document, user=request.user, role=serializer.validated_data["role"], ) + # Send email to document owners/admins + owner_admin_accesses = models.DocumentAccess.objects.filter( + document=document, role__in=models.PRIVILEGED_ROLES + ).select_related("user") + + for access in owner_admin_accesses: + if access.user and access.user.email: + ask_for_access.send_ask_for_access_email( + access.user.email, + request.user, + access.user.language or settings.LANGUAGE_CODE, + ) + return drf.response.Response(status=drf.status.HTTP_201_CREATED) @drf.decorators.action(detail=True, methods=["post"]) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f137802d0a..87b4d2cdbb 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -876,8 +876,8 @@ def send_email(self, subject, emails, context=None, language=None): ) with override(language): - msg_html = render_to_string("mail/html/invitation.html", context) - msg_plain = render_to_string("mail/text/invitation.txt", context) + msg_html = render_to_string("mail/html/template.html", context) + msg_plain = render_to_string("mail/text/template.txt", context) subject = str(subject) # Force translation try: @@ -1221,6 +1221,38 @@ def accept(self, role=None): ) self.delete() + def send_ask_for_access_email(self, email, sender, language=None): + """ + Method allowing a user to send an email notification when asking for access to a document. + """ + + language = language or get_language() + sender_name = sender.full_name or sender.email + sender_name_email = ( + f"{sender.full_name:s} ({sender.email})" + if sender.full_name + else sender.email + ) + + with override(language): + context = { + "title": _("{name} would like access to a document!").format( + name=sender_name + ), + "message": _( + "{name} would like access to the following document:" + ).format(name=sender_name_email), + } + subject = ( + context["title"] + if not self.document.title + else _("{name} is asking for access to the document: {title}").format( + name=sender_name, title=self.document.title + ) + ) + + self.document.send_email(subject, [email], context, language) + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" diff --git a/src/mail/mjml/invitation.mjml b/src/mail/mjml/template.mjml similarity index 100% rename from src/mail/mjml/invitation.mjml rename to src/mail/mjml/template.mjml From c274d6fc9cc618f0095a0d437a29653d2639a8bb Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 20 Jun 2025 15:35:19 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=90=9B(env)=20update=20yprovider=20?= =?UTF-8?q?env=20for=20local=20development?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In local development the notification to the yprovider server was not working anymore because of a recent change in the container name. We adapt the env variables to match the new container name. --- env.d/development/common.dist | 4 ++-- env.d/development/common.e2e.dist | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 521db75935..f4a601c7ca 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -56,11 +56,11 @@ AI_API_KEY=password AI_MODEL=llama # Collaboration -COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ +COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/ COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_SECRET=my-secret COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ -Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/ +Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/ Y_PROVIDER_API_KEY=yprovider-api-key diff --git a/env.d/development/common.e2e.dist b/env.d/development/common.e2e.dist index 3b2e2afc9c..b5a5113623 100644 --- a/env.d/development/common.e2e.dist +++ b/env.d/development/common.e2e.dist @@ -1,4 +1,6 @@ # For the CI job test-e2e BURST_THROTTLE_RATES="200/minute" +COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e SUSTAINED_THROTTLE_RATES="200/hour" +Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/ \ No newline at end of file From 1f1c7b022a83736b337751a125f7c862203e97ec Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 20 Jun 2025 17:34:04 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20improve=20?= =?UTF-8?q?separation=20of=20concerns=20in=20DocShareModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve separation of concerns in the DocShareModal component. The member and invitation list are now in a separate component. It will help us to integrate cleanly the request access list. --- ...itationItem.tsx => DocShareInvitation.tsx} | 107 ++++++++++++++++- ...ShareMemberItem.tsx => DocShareMember.tsx} | 67 ++++++++++- .../doc-share/components/DocShareModal.tsx | 113 ++---------------- .../DocShareModalInviteUserByEmail.tsx | 42 ------- 4 files changed, 176 insertions(+), 153 deletions(-) rename src/frontend/apps/impress/src/features/docs/doc-share/components/{DocShareInvitationItem.tsx => DocShareInvitation.tsx} (50%) rename src/frontend/apps/impress/src/features/docs/doc-share/components/{DocShareMemberItem.tsx => DocShareMember.tsx} (61%) delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalInviteUserByEmail.tsx diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx similarity index 50% rename from src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx rename to src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx index 76a04fbd02..91507915f2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx @@ -1,30 +1,44 @@ import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import { Box, DropdownMenu, DropdownMenuOption, + Icon, IconOptions, + LoadMoreText, + Text, } from '@/components'; +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; import { useCunninghamTheme } from '@/cunningham'; import { Doc, Role } from '@/docs/doc-management'; import { User } from '@/features/auth'; -import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api'; +import { + useDeleteDocInvitation, + useDocInvitationsInfinite, + useUpdateDocInvitation, +} from '../api'; import { Invitation } from '../types'; import { DocRoleDropdown } from './DocRoleDropdown'; import { SearchUserRow } from './SearchUserRow'; -type Props = { +type DocShareInvitationItemProps = { doc: Doc; invitation: Invitation; }; -export const DocShareInvitationItem = ({ doc, invitation }: Props) => { + +const DocShareInvitationItem = ({ + doc, + invitation, +}: DocShareInvitationItemProps) => { const { t } = useTranslation(); const { spacingsTokens } = useCunninghamTheme(); - const fakeUser: User = { + const invitedUser: User = { id: invitation.email, full_name: invitation.email, email: invitation.email, @@ -79,6 +93,7 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { disabled: !canUpdate, }, ]; + return ( { { ); }; + +type DocShareModalInviteUserRowProps = { + user: User; +}; +export const DocShareModalInviteUserRow = ({ + user, +}: DocShareModalInviteUserRowProps) => { + const { t } = useTranslation(); + return ( + + + + {t('Add')} + + + + } + /> + + ); +}; + +interface QuickSearchGroupInvitationProps { + doc: Doc; +} + +export const QuickSearchGroupInvitation = ({ + doc, +}: QuickSearchGroupInvitationProps) => { + const { t } = useTranslation(); + const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({ + docId: doc.id, + }); + + const invitationsData: QuickSearchData = useMemo(() => { + const invitations = data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: t('Pending invitations'), + elements: invitations, + endActions: hasNextPage + ? [ + { + content: , + onSelect: () => void fetchNextPage(), + }, + ] + : undefined, + }; + }, [data?.pages, fetchNextPage, hasNextPage, t]); + + if (!invitationsData.elements.length) { + return null; + } + + return ( + + ( + + )} + /> + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx similarity index 61% rename from src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx rename to src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx index 4da05ec71b..8109325d9e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx @@ -1,4 +1,5 @@ import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -6,13 +7,19 @@ import { DropdownMenu, DropdownMenuOption, IconOptions, + LoadMoreText, } from '@/components'; +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; import { useCunninghamTheme } from '@/cunningham'; import { Access, Doc, Role } from '@/docs/doc-management/'; import { useResponsiveStore } from '@/stores'; -import { useDeleteDocAccess, useUpdateDocAccess } from '../api'; -import { useWhoAmI } from '../hooks/'; +import { + useDeleteDocAccess, + useDocAccessesInfinite, + useUpdateDocAccess, +} from '../api'; +import { useWhoAmI } from '../hooks'; import { DocRoleDropdown } from './DocRoleDropdown'; import { SearchUserRow } from './SearchUserRow'; @@ -21,7 +28,8 @@ type Props = { doc: Doc; access: Access; }; -export const DocShareMemberItem = ({ doc, access }: Props) => { + +const DocShareMemberItem = ({ doc, access }: Props) => { const { t } = useTranslation(); const { isLastOwner } = useWhoAmI(access); const { toast } = useToastProvider(); @@ -36,7 +44,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { const { mutate: updateDocAccess } = useUpdateDocAccess({ onError: () => { - toast(t('Error during invitation update'), VariantType.ERROR, { + toast(t('Error while updating the member role.'), VariantType.ERROR, { duration: 4000, }); }, @@ -44,7 +52,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { const { mutate: removeDocAccess } = useDeleteDocAccess({ onError: () => { - toast(t('Error while deleting invitation'), VariantType.ERROR, { + toast(t('Error while deleting the member.'), VariantType.ERROR, { duration: 4000, }); }, @@ -105,3 +113,52 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { ); }; + +interface QuickSearchGroupMemberProps { + doc: Doc; +} + +export const QuickSearchGroupMember = ({ + doc, +}: QuickSearchGroupMemberProps) => { + const { t } = useTranslation(); + const membersQuery = useDocAccessesInfinite({ + docId: doc.id, + }); + + const membersData: QuickSearchData = useMemo(() => { + const members = + membersQuery.data?.pages.flatMap((page) => page.results) || []; + + const count = membersQuery.data?.pages[0]?.count ?? 1; + + return { + groupName: + count === 1 + ? t('Document owner') + : t('Share with {{count}} users', { + count: count, + }), + elements: members, + endActions: membersQuery.hasNextPage + ? [ + { + content: , + onSelect: () => void membersQuery.fetchNextPage(), + }, + ] + : undefined, + }; + }, [membersQuery, t]); + + return ( + + ( + + )} + /> + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index 54c36d24fb..ebd3f3f2cc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -4,30 +4,26 @@ import { useTranslation } from 'react-i18next'; import { createGlobalStyle, css } from 'styled-components'; import { useDebouncedCallback } from 'use-debounce'; -import { Box, HorizontalSeparator, LoadMoreText, Text } from '@/components'; +import { Box, HorizontalSeparator, Text } from '@/components'; import { QuickSearch, QuickSearchData, QuickSearchGroup, } from '@/components/quick-search/'; import { User } from '@/features/auth'; -import { Access, Doc } from '@/features/docs'; +import { Doc } from '@/features/docs'; import { useResponsiveStore } from '@/stores'; import { isValidEmail } from '@/utils'; -import { - KEY_LIST_USER, - useDocAccessesInfinite, - useDocInvitationsInfinite, - useUsers, -} from '../api'; -import { Invitation } from '../types'; +import { KEY_LIST_USER, useUsers } from '../api'; import { DocShareAddMemberList } from './DocShareAddMemberList'; -import { DocShareInvitationItem } from './DocShareInvitationItem'; -import { DocShareMemberItem } from './DocShareMemberItem'; +import { + DocShareModalInviteUserRow, + QuickSearchGroupInvitation, +} from './DocShareInvitation'; +import { QuickSearchGroupMember } from './DocShareMember'; import { DocShareModalFooter } from './DocShareModalFooter'; -import { DocShareModalInviteUserRow } from './DocShareModalInviteUserByEmail'; const ShareModalStyle = createGlobalStyle` .c__modal__title { @@ -66,10 +62,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => { setInputValue(''); }; - const membersQuery = useDocAccessesInfinite({ - docId: doc.id, - }); - const searchUsersQuery = useUsers( { query: userQuery, docId: doc.id }, { @@ -78,31 +70,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => { }, ); - const membersData: QuickSearchData = useMemo(() => { - const members = - membersQuery.data?.pages.flatMap((page) => page.results) || []; - - const count = membersQuery.data?.pages[0]?.count ?? 1; - - return { - groupName: - count === 1 - ? t('Document owner') - : t('Share with {{count}} users', { - count: count, - }), - elements: members, - endActions: membersQuery.hasNextPage - ? [ - { - content: , - onSelect: () => void membersQuery.fetchNextPage(), - }, - ] - : undefined, - }; - }, [membersQuery, t]); - const onFilter = useDebouncedCallback((str: string) => { setUserQuery(str); }, 300); @@ -205,10 +172,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => { placeholder={t('Type a name or email')} > {showMemberSection ? ( - + <> + + + ) : ( ); }; - -interface QuickSearchMemberSectionProps { - doc: Doc; - membersData: QuickSearchData; -} - -const QuickSearchMemberSection = ({ - doc, - membersData, -}: QuickSearchMemberSectionProps) => { - const { t } = useTranslation(); - const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({ - docId: doc.id, - }); - - const invitationsData: QuickSearchData = useMemo(() => { - const invitations = data?.pages.flatMap((page) => page.results) || []; - - return { - groupName: t('Pending invitations'), - elements: invitations, - endActions: hasNextPage - ? [ - { - content: , - onSelect: () => void fetchNextPage(), - }, - ] - : undefined, - }; - }, [data?.pages, fetchNextPage, hasNextPage, t]); - - return ( - <> - {invitationsData.elements.length > 0 && ( - - ( - - )} - /> - - )} - - - ( - - )} - /> - - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalInviteUserByEmail.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalInviteUserByEmail.tsx deleted file mode 100644 index 35f741faec..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalInviteUserByEmail.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { Box, Icon, Text } from '@/components'; -import { User } from '@/features/auth'; - -import { SearchUserRow } from './SearchUserRow'; - -type Props = { - user: User; -}; -export const DocShareModalInviteUserRow = ({ user }: Props) => { - const { t } = useTranslation(); - return ( - - - - {t('Add')} - - - - } - /> - - ); -}; From bf0e81e40c879f1b3425a6097f0c7378b36ef01c Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 20 Jun 2025 18:35:11 +0200 Subject: [PATCH 09/10] =?UTF-8?q?fixup!=20=E2=9C=A8(back)=20document=20as?= =?UTF-8?q?=20for=20access=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/api/serializers.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d22ccf5116..193b31f4f5 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -686,10 +686,18 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer): """Serializer for document ask for access model""" abilities = serializers.SerializerMethodField(read_only=True) + user_id = serializers.PrimaryKeyRelatedField( + queryset=models.User.objects.all(), + write_only=True, + source="user", + required=False, + allow_null=True, + ) + user = UserSerializer(read_only=True) class Meta: model = models.DocumentAskForAccess - fields = ["id", "document", "user", "role", "created_at", "abilities"] + fields = ["id", "document", "user", "user_id","role", "created_at", "abilities"] read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"] def get_abilities(self, invitation) -> dict: From 4f83a603c3479c7fce26049d92bb0803368923c2 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 20 Jun 2025 18:39:16 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20access=20requ?= =?UTF-8?q?est=20list=20on=20doc=20share=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the access request list to the document share modal, allowing admin to see and manage access requests directly from the modal interface. --- .../doc-share/api/useDocAccessRequest.tsx | 18 ++- .../components/DocShareAccessRequest.tsx | 142 ++++++++++++++++++ .../doc-share/components/DocShareModal.tsx | 2 + 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx index 062edba67b..559761dc44 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx @@ -6,7 +6,13 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; +import { + APIError, + APIList, + errorCauses, + fetchAPI, + useAPIInfiniteQuery, +} from '@/api'; import { AccessRequest, Doc, Role } from '@/docs/doc-management'; import { OptionType } from '../types'; @@ -100,3 +106,13 @@ export function useDocAccessRequests( ...queryConfig, }); } + +export const useDocAccessRequestsInfinite = ( + params: GetDocAccessRequestsParams, +) => { + return useAPIInfiniteQuery( + KEY_LIST_DOC_ACCESS_REQUESTS, + getDocAccessRequests, + params, + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx new file mode 100644 index 0000000000..faef1ab1b3 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx @@ -0,0 +1,142 @@ +import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Box, + DropdownMenu, + DropdownMenuOption, + IconOptions, + LoadMoreText, +} from '@/components'; +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; +import { useCunninghamTheme } from '@/cunningham'; +import { AccessRequest, Doc, Role } from '@/docs/doc-management/'; +import { useResponsiveStore } from '@/stores'; + +import { useDeleteDocAccess, useUpdateDocAccess } from '../api'; +import { useDocAccessRequestsInfinite } from '../api/useDocAccessRequest'; + +import { DocRoleDropdown } from './DocRoleDropdown'; +import { SearchUserRow } from './SearchUserRow'; + +type Props = { + doc: Doc; + accessRequest: AccessRequest; +}; + +const DocShareAccessRequestItem = ({ doc, accessRequest }: Props) => { + const { t } = useTranslation(); + const { toast } = useToastProvider(); + const { isDesktop } = useResponsiveStore(); + const { spacingsTokens } = useCunninghamTheme(); + + const { mutate: updateDocAccess } = useUpdateDocAccess({ + onError: () => { + toast(t('Error during invitation update'), VariantType.ERROR, { + duration: 4000, + }); + }, + }); + + const { mutate: removeDocAccess } = useDeleteDocAccess({ + onError: () => { + toast(t('Error while deleting invitation'), VariantType.ERROR, { + duration: 4000, + }); + }, + }); + + const onUpdate = (newRole: Role) => { + updateDocAccess({ + docId: doc.id, + role: newRole, + accessId: accessRequest.id, + }); + }; + + const onRemove = () => { + removeDocAccess({ accessId: accessRequest.id, docId: doc.id }); + }; + + const moreActions: DropdownMenuOption[] = [ + { + label: t('Delete'), + icon: 'delete', + callback: onRemove, + disabled: !accessRequest.abilities.destroy, + }, + ]; + + return ( + + + + + {isDesktop && doc.abilities.accesses_manage && ( + + + + )} + + } + /> + + ); +}; + +interface QuickSearchGroupAccessRequestProps { + doc: Doc; +} + +export const QuickSearchGroupAccessRequest = ({ + doc, +}: QuickSearchGroupAccessRequestProps) => { + const { t } = useTranslation(); + const accessRequestQuery = useDocAccessRequestsInfinite({ docId: doc.id }); + + const accessRequestsData: QuickSearchData = useMemo(() => { + const accessRequests = + accessRequestQuery.data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: t('Access Requests'), + elements: accessRequests, + endActions: accessRequestQuery.hasNextPage + ? [ + { + content: , + onSelect: () => void accessRequestQuery.fetchNextPage(), + }, + ] + : undefined, + }; + }, [accessRequestQuery, t]); + + return ( + + ( + + )} + /> + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index ebd3f3f2cc..8c76bcd1ac 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -17,6 +17,7 @@ import { isValidEmail } from '@/utils'; import { KEY_LIST_USER, useUsers } from '../api'; +import { QuickSearchGroupAccessRequest } from './DocShareAccessRequest'; import { DocShareAddMemberList } from './DocShareAddMemberList'; import { DocShareModalInviteUserRow, @@ -173,6 +174,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => { > {showMemberSection ? ( <> +