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 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index e86288bb34..193b31f4f5 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -664,6 +664,50 @@ 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, + ) + + +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", "user_id","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..1506839a30 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1772,6 +1772,95 @@ 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, + ) + + 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"]) + 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/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..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: @@ -1149,6 +1149,111 @@ 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, + "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() + + 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/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..3759662d61 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_ask_for_access.py @@ -0,0 +1,613 @@ +"""Test API for document ask for access.""" + +import uuid + +import pytest +from rest_framework.test import APIClient + +from core.factories import ( + DocumentAskForAccessFactory, + DocumentFactory, + UserDocumentAccessFactory, + UserFactory, +) +from core.models import DocumentAccess, 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": { + "accept": False, + "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": { + "accept": True, + "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": { + "accept": True, + "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() + + +## 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 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() 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/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/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/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, }); } 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..559761dc44 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx @@ -0,0 +1,118 @@ +import { + UseMutationOptions, + UseQueryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import { + APIError, + APIList, + errorCauses, + fetchAPI, + useAPIInfiniteQuery, +} 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, + }); +} + +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/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..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 @@ -4,30 +4,27 @@ 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 { QuickSearchGroupAccessRequest } from './DocShareAccessRequest'; 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 +63,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => { setInputValue(''); }; - const membersQuery = useDocAccessesInfinite({ - docId: doc.id, - }); - const searchUsersQuery = useUsers( { query: userQuery, docId: doc.id }, { @@ -78,31 +71,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 +173,11 @@ 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')} - - - - } - /> - - ); -}; 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 854187ceb0..278f103737 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,28 @@ 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)) { + let replacePath = `/${error.status}`; + + if (error.status === 401) { + if (authenticated) { + queryClient.setQueryData([KEY_AUTH], { + user: null, + authenticated: false, + }); + } + setAuthUrl(); + } else if (error.status === 403) { + replacePath = `/docs/${id}/403`; + } + + void replace(replacePath); + + return ( + + + + ); } return ( 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