Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/backend/core/tests/documents/test_api_document_invitations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
32 changes: 31 additions & 1 deletion src/backend/core/tests/test_models_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import pytest

from core import factories
from core import factories, models

pytestmark = pytest.mark.django_db

Expand Down Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export const DocShareAddMemberList = ({
return isInvitationMode
? createInvitation({
...payload,
email: user.email,
email: user.email.toLowerCase(),
})
: createDocAccess({
...payload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading