diff --git a/CHANGELOG.md b/CHANGELOG.md index b644f79eb0..15b62c6f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to - 🐛(frontend) fix attachment download filename #1447 - 🐛(frontend) exclude h4-h6 headings from table of contents #1441 - 🔒(frontend) prevent readers from changing callout emoji #1449 +- 🐛(backend) filter invitation with case insensitive email ## [3.7.0] - 2025-09-12 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index a4e5d9d8e3..fe94cd5f43 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -740,6 +740,8 @@ def validate(self, attrs): if self.instance is None: attrs["issuer"] = user + attrs["email"] = attrs["email"].lower() + return attrs def validate_role(self, role): diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 9b8e13fec1..d5e8cf9a8f 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -221,7 +221,7 @@ def _convert_valid_invitations(self): Expired invitations are ignored. """ valid_invitations = Invitation.objects.filter( - email=self.email, + email__iexact=self.email, created_at__gte=( timezone.now() - timedelta(seconds=settings.INVITATION_VALIDITY_DURATION) diff --git a/src/backend/core/tests/documents/test_api_document_invitations.py b/src/backend/core/tests/documents/test_api_document_invitations.py index 33d99d8a8f..94c5f88131 100644 --- a/src/backend/core/tests/documents/test_api_document_invitations.py +++ b/src/backend/core/tests/documents/test_api_document_invitations.py @@ -596,6 +596,32 @@ def test_api_document_invitations_create_cannot_invite_existing_users(): } +def test_api_document_invitations_create_lower_email(): + """ + No matter the case, the email should be converted to lowercase. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "owner")]) + + # Build an invitation to the email of an existing identity in the db + invitation_values = { + "email": "GuEst@example.com", + "role": random.choice(models.RoleChoices.values), + } + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/invitations/", + invitation_values, + format="json", + ) + + assert response.status_code == 201 + assert response.json()["email"] == "guest@example.com" + + # Update diff --git a/src/backend/core/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py index e7be9267cb..ff2bbea615 100644 --- a/src/backend/core/tests/test_models_users.py +++ b/src/backend/core/tests/test_models_users.py @@ -8,7 +8,7 @@ import pytest -from core import factories +from core import factories, models pytestmark = pytest.mark.django_db @@ -66,3 +66,33 @@ def test_models_users_sub_validator(sub, is_valid): match=("Enter a valid sub. This value should be ASCII only."), ): user.full_clean() + + +def test_modes_users_convert_valid_invitations(): + """ + The "convert_valid_invitations" method should convert valid invitations to document accesses. + """ + email = "test@example.com" + document = factories.DocumentFactory() + other_document = factories.DocumentFactory() + invitation_document = factories.InvitationFactory(email=email, document=document) + invitation_other_document = factories.InvitationFactory( + email="Test@example.coM", document=other_document + ) + other_email_invitation = factories.InvitationFactory( + email="pre_test@example.com", document=document + ) + + assert document.accesses.count() == 0 + assert other_document.accesses.count() == 0 + + user = factories.UserFactory(email=email) + + assert document.accesses.filter(user=user).count() == 1 + assert other_document.accesses.filter(user=user).count() == 1 + + assert not models.Invitation.objects.filter(id=invitation_document.id).exists() + assert not models.Invitation.objects.filter( + id=invitation_other_document.id + ).exists() + assert models.Invitation.objects.filter(id=other_email_invitation.id).exists() diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts index d426ac50d9..623b804368 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts @@ -169,8 +169,15 @@ test.describe('Document create member', () => { const inputSearch = page.getByTestId('quick-search-input'); - const email = randomName('test@test.fr', browserName, 1)[0]; + let email = 'user.test21@example.COM'; await inputSearch.fill(email); + + // Check email is found in search (case insensitive) + await expect(page.getByRole('option').getByText(email)).toHaveCount(1); + + email = email + 'M'; + await inputSearch.fill(email); + await page.getByTestId(`search-user-row-${email}`).click(); // Choose a role @@ -191,7 +198,7 @@ test.describe('Document create member', () => { const listInvitation = page.getByTestId('doc-share-quick-search'); const userInvitation = listInvitation.getByTestId( - `doc-share-invitation-row-${email}`, + `doc-share-invitation-row-${email.toLowerCase()}`, ); await expect(userInvitation).toBeVisible(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx index 351dca4a59..b797eb96ca 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx @@ -91,7 +91,7 @@ export const DocShareAddMemberList = ({ return isInvitationMode ? createInvitation({ ...payload, - email: user.email, + email: user.email.toLowerCase(), }) : createDocAccess({ ...payload, 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 c214b1ed16..54d99c930d 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 @@ -296,7 +296,9 @@ const QuickSearchInviteInputSection = ({ language: '', }; - const hasEmailInUsers = users.some((user) => user.email === userQuery); + const hasEmailInUsers = users.some( + (user) => user.email.toLowerCase() === userQuery.toLowerCase(), + ); return { groupName: t('Search user result'), diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx index 109a60763f..529626109d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx @@ -21,7 +21,7 @@ export const SearchUserRow = ({ alwaysShowRight = false, isInvitation = false, }: Props) => { - const hasFullName = user.full_name != null && user.full_name !== ''; + const hasFullName = !!user.full_name; const { spacingsTokens, colorsTokens } = useCunninghamTheme(); return (