Compare commits

..

29 Commits

Author SHA1 Message Date
Eléonore Voisin 7522569ce0 Try to fix test 2025-02-07 10:05:03 +01:00
Quentin BEY 351c696ef8 🐛(tilt) fix the dev-keycloak configuration
The mailcatcher configuration was missing from the
configuration file when using keycloak.
2025-02-06 10:38:46 +01:00
Sabrina Demagny 579dbdee10 (api) add count mailboxes to MailDomain serializer
Return number of mailboxes of a domain in our API.
2025-02-04 15:22:00 +01:00
Quentin BEY b4a877381a 🐛(teams) disable creation endpoint from abilities
When we don't allow the user to see the team creation button,
we also want to disable the corresponding API.
2025-02-04 15:20:48 +01:00
Quentin BEY 92753082c7 🚑️(teams) hide display add button when disallowed
The frontend should not display "add team" actions when the
user has not the ability.
2025-02-04 15:20:48 +01:00
Sabrina Demagny 4df4172151 (dimail) manage 'action required' status for MailDomain
Adapt fetch domain status call to manage internal and external
fixes required. Use the new status 'action required' to
manage actions expected from support.
Call a new dimail endpoint to run a fix for internal checks
when all external checks are OK.
2025-02-04 14:13:36 +01:00
Laurent Bossavit e7af1fd591 🐛(frontend) improve e2e tests avoiding race condition from mocks
Reorder mock setup with page.route so that it takes place before nav.
2025-02-04 14:03:35 +01:00
Sabrina Demagny 8a2b0d0a76 🐛(backend) fix flaky test after add fr translations
Define user language to test invitation email content.
2025-02-03 15:24:39 +01:00
Sabrina Demagny d48a3ff677 (domains) add action required status on MailDomain
Create a new domain status to handle cases where
action is required from an external domain owner
for a domain to be fully functional.
2025-02-03 15:24:32 +01:00
renovate[bot] 4fb5d87df9 ⬆️(dependencies) update python dependencies 2025-02-03 14:30:56 +01:00
renovate[bot] 6274764f90 ⬆️(dependencies) update js dependencies 2025-02-03 14:18:33 +01:00
Marie PUPO JEAMMET 7f0e231474 (api) restrict mailbox sync to enabled domains
Pending, failed and deactivated domains should not be sync'ed.
2025-02-03 13:47:02 +01:00
Marie PUPO JEAMMET 2c15609c1e 🎨(dimail) factorize dimail response upon successful mailbox creation
dimail's ok response upon mailbox creation is used in several tests.
All those tests now reference proper response available in fixtures.
2025-02-03 13:47:02 +01:00
Marie PUPO JEAMMET cd94dc5091 (dimail) send pending mailboxes upon domain activation
send creation requests to dimail for all pending mailboxes
when domain goes from "pending" to "enabled".
2025-02-03 13:47:02 +01:00
Sabrina Demagny 9d9216cf39 🌐(backend) add missing translations
Regenerate translations, translate some and
replace "Desk" and "Régie" to "La Régie".
2025-02-03 13:24:40 +01:00
Quentin BEY 7dd9eae5d9 💚(argocd) humble try to fix the webhook call
This is an attempt to fix:
`Webhook processing failed: HMAC verification failed`
2025-02-03 13:12:42 +01:00
Quentin BEY 914319c366 💚(argocd) fix deployment command
The command should use organization variables.
2025-02-03 12:48:50 +01:00
Quentin BEY c34ad00fae 💚(docker) fix docker login command
Use the secret from github organization.
2025-02-03 12:35:12 +01:00
Quentin BEY 289879962b 🧑‍💻(tilt) add mailcatcher to the stack
This allows to start a mailcatcher for local developments.
2025-02-03 12:21:34 +01:00
Quentin BEY cd88799943 💚(github) remove secret fetch
The secrets are not managed in the folder anymore.
2025-01-30 15:55:58 +01:00
Quentin BEY 4011c8e8ed 🚑️(plugins) fix name from SIRET specific case
For some SIRET, there are no "liste_enseignes" (null), in such
cases we fallback on the global "company" name.
2025-01-30 15:55:58 +01:00
Sabrina Demagny b98281fa72 🔖(patch) release version 1.10.1
Update all version files and changelog for patch release.
2025-01-27 15:11:01 +01:00
Sabrina Demagny 04bd154bad (scripts) improve release script
One of instructions was in a wrong place.
Release tag on github belongs to the project part.
2025-01-27 14:45:58 +01:00
Marie PUPO JEAMMET 227ecd0700 🐛(dimail) fix imported mailboxes should be enabled instead of pending
Importing mailboxes creates pending mailboxes ... but these mailboxes are
already active and, for most, functional. We thus mark them as "enabled".
2025-01-27 11:23:26 +01:00
renovate[bot] 6d618b4aff ⬆️(dependencies) update python dependencies 2025-01-27 11:09:38 +01:00
renovate[bot] 3a2b2b8a53 ⬆️(dependencies) update js dependencies 2025-01-27 09:33:51 +01:00
Sabrina Demagny f969b118cb (dimail) management command to fetch domain status
Add a management command for a future cron to
to check and update regularly domains status
from dimail.
2025-01-24 19:33:45 +01:00
Sabrina Demagny c48957612b ️(api) add missing cache for stats endpoint
Add cache to display stats for anonymous
and improve some code.
2025-01-24 16:52:02 +01:00
Sabrina Demagny e09e1d1b80 (scripts) adapts release script
Adapts the release script after moving
the deployment part to a new repository.
2025-01-23 18:14:00 +01:00
57 changed files with 1885 additions and 794 deletions
+1 -18
View File
@@ -11,26 +11,9 @@ jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
+8 -70
View File
@@ -19,20 +19,9 @@ jobs:
trivy-scan:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -57,36 +46,19 @@ jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-backend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
@@ -108,32 +80,15 @@ jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-frontend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
@@ -144,7 +99,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
@@ -163,29 +118,12 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
+31 -1
View File
@@ -8,6 +8,34 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(api) add count mailboxes to MailDomain serializer
- ✨(dimail) manage 'action required' status for MailDomain
- ✨(domains) add action required status on MailDomain
- ✨(dimail) send pending mailboxes upon domain activation
### Fixed
- 🚑️(teams) do not display add button when disallowed #676
- 🚑️(plugins) fix name from SIRET specific case #674
- 🐛(api) restrict mailbox sync to enabled domains
## [1.10.1] - 2025-01-27
### Added
- ✨(dimail) management command to fetch domain status
### Changed
- ✨(scripts) adapts release script after moving the deployment part
### Fixed
- 🐛(dimail) fix imported mailboxes should be enabled instead of pending #659
- ⚡️(api) add missing cache for stats endpoint
## [1.10.0] - 2025-01-21
### Added
@@ -21,6 +49,7 @@ and this project adheres to
### Fixed
- 🐛(frontend) improve e2e tests avoiding race condition from mocks #641
- 🐛(backend) fix flaky test with search contact #605
- 🐛(backend) fix flaky test with team access #646
- 🧑‍💻(dimail) remove 'NoneType: None' log in debug mode
@@ -247,7 +276,8 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.10.0...main
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.10.1...main
[1.10.1]: https://github.com/numerique-gouv/people/releases/v1.10.1
[1.10.0]: https://github.com/numerique-gouv/people/releases/v1.10.0
[1.9.1]: https://github.com/numerique-gouv/people/releases/v1.9.1
[1.9.0]: https://github.com/numerique-gouv/people/releases/v1.9.0
+4
View File
@@ -403,3 +403,7 @@ install-secret: ## install the kubernetes secrets from Vaultwarden
--set installCRDs=true; \
fi
.PHONY: build-k8s-cluster
fetch-domain-status:
@$(MANAGE) fetch_domain_status
.PHONY: fetch-domain-status
+66 -25
View File
@@ -24,18 +24,6 @@ def update_files(version):
with open(path, 'w+') as file:
file.writelines(lines)
# helm files
sys.stdout.write("Update helm files...\n")
for env in ["preprod", "production"]:
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "tag:" in line:
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
# frontend package.json
sys.stdout.write("Update package.json...\n")
files_to_modify = []
@@ -55,6 +43,17 @@ def update_files(version):
return
def update_helm_files(path):
sys.stdout.write("Update helm files...\n")
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "tag:" in line:
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
def update_changelog(path, version):
"""Update changelog file with release info
"""
@@ -77,7 +76,43 @@ def update_changelog(path, version):
file.writelines(lines)
def prepare_release(version, kind):
def deployment_part(version, kind):
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
"""
# Move to lasuite-deploiement repository
try:
os.chdir('../lasuite-deploiement')
except FileNotFoundError:
sys.stdout.write("\033[1;31m[Error] You must have a clone of https://github.com/numerique-gouv/lasuite-deploiement\x1b[0m")
sys.stdout.write("\033[0;32mPlease clone it and re-run script with option --only-deployment-part\x1b[0m")
sys.stdout.write(" >>> python scripts/release.py --only-deployment-part \x1b[0m")
else:
run_command("git checkout main", shell=True)
run_command("git pull", shell=True)
deployment_branch = f"regie/{version}/preprod"
run_command(f"git checkout -b {deployment_branch}", shell=True)
run_command("git pull --rebase origin main", shell=True)
path = f"manifests/regie/env.d/preprod/values.desk.yaml.gotmpl"
update_helm_files(path)
run_command(f"git add {path}", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}"""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"""\033[0;32m
### DEPLOYMENT ###
NEXT COMMAND on lasuite-deploiement repository:
>> git push origin {deployment_branch}
Continue ? (y,n)
\x1b[0m""")
if confirm == 'y':
run_command(f"git push origin {deployment_branch}", shell=True)
sys.stdout.write(f"""\033[1;34m
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR {deployment_branch} and merge code to main
\x1b[0m""")
def project_part(version, kind):
"""Manage only la regie part with update of CHANGELOG, version files and tag"""
sys.stdout.write('Let\'s go to create branch to release\n')
branch_to_release = f"release/{version}"
run_command(f"git checkout -b {branch_to_release}", shell=True)
@@ -90,25 +125,29 @@ def prepare_release(version, kind):
run_command("git add src/", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
confirm = input(f"""\033[0;32m
### RELEASE ###
NEXT COMMAND on current repository:
>> git push origin {branch_to_release}
Continue ? (y,n)
\x1b[0m""")
if confirm == 'y':
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR and merge code to main
--> Then do:
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR {branch_to_release} and merge code to main
--> Then do:
>> git checkout main
>> git pull
>> git tag v{version}
>> git push origin v{version}
--> Please check and wait for the docker image v{version} to be published here: https://hub.docker.com/r/lasuite/people-backend/tags and https://hub.docker.com/r/lasuite/people-frontend/tags
>> git tag -d preprod
>> git tag preprod
>> git push -f origin preprod
--> Now please generate release on github interface for the current tag v{version}
""")
--> Please check and wait for the docker image v{version} to be published here:
- https://hub.docker.com/r/lasuite/people-backend/tags
- https://hub.docker.com/r/lasuite/people-frontend/tags
--> Now please generate release on github interface for the current tag v{version}
\x1b[0m""")
if __name__ == "__main__":
@@ -117,4 +156,6 @@ if __name__ == "__main__":
version = input("Enter your release version:")
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
prepare_release(version, kind)
if "--only-deployment-part" not in sys.argv:
project_part(version, kind)
deployment_part(version, kind)
+8 -7
View File
@@ -7,6 +7,8 @@ from functools import reduce
from django.conf import settings
from django.db.models import OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import (
decorators,
@@ -28,8 +30,6 @@ from core.utils.raw_sql import gen_sql_filter_json_array
from mailbox_manager import models as domains_models
SIMILARITY_THRESHOLD = 0.04
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -303,7 +303,7 @@ class TeamViewSet(
):
"""Team ViewSet"""
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.TeamPermission, permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "name", "path"]
@@ -596,19 +596,20 @@ class StatView(views.APIView):
permission_classes = [AllowAny]
@method_decorator(cache_page(3600))
def get(self, request):
"""
GET /api/v1.0/stats/
Return a dictionary of public metrics.
"""
context = {
"total_users": models.User.objects.all().count(),
"total_users": models.User.objects.count(),
"mau": models.User.objects.filter(
last_login__gte=datetime.datetime.now() - datetime.timedelta(30)
).count(),
"teams": models.Team.objects.all().count(),
"domains": domains_models.MailDomain.objects.all().count(),
"mailboxes": domains_models.Mailbox.objects.all().count(),
"teams": models.Team.objects.count(),
"domains": domains_models.MailDomain.objects.count(),
"mailboxes": domains_models.Mailbox.objects.count(),
}
return response.Response(context)
+15
View File
@@ -53,3 +53,18 @@ class AccessPermission(IsAuthenticated):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class TeamPermission(IsAuthenticated):
"""Permission class for team objects viewset."""
def has_permission(self, request, view):
"""Check permission only when the user tries to create a new team."""
if not super().has_permission(request, view):
return False
if request.method != "POST":
return True
abilities = request.user.get_abilities()
return abilities["teams"]["can_create"]
+6 -5
View File
@@ -21,8 +21,8 @@ from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext, override
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import jsonschema
from timezone_field import TimeZoneField
@@ -573,7 +573,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
.get()
)
teams_can_view = user_info.teams_can_view
teams_can_view = user_info.teams_can_view or settings.FEATURES["TEAMS_DISPLAY"]
mailboxes_can_view = user_info.mailboxes_can_view
return {
@@ -585,7 +585,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
},
"teams": {
"can_view": teams_can_view and settings.FEATURES["TEAMS_DISPLAY"],
"can_view": teams_can_view,
"can_create": teams_can_view and settings.FEATURES["TEAMS_CREATE"],
},
"mailboxes": {
@@ -961,14 +961,15 @@ class Invitation(BaseModel):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
subject = gettext("Invitation to join La Régie!")
template_vars = {
"title": _("Invitation to join Desk!"),
"title": subject,
"site": Site.objects.get_current(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
_("Invitation to join Desk!"),
subject,
msg_plain,
settings.EMAIL_FROM,
[self.email],
@@ -6,6 +6,7 @@ import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
@@ -28,7 +29,7 @@ def test_api_teams_create_anonymous():
assert not Team.objects.exists()
def test_api_teams_create_authenticated():
def test_api_teams_create_authenticated(settings):
"""
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team.
@@ -39,6 +40,14 @@ def test_api_teams_create_authenticated():
client = APIClient()
client.force_login(user)
settings.FEATURES = {
"TEAMS_DISPLAY": True,
"TEAMS_CREATE": True,
"CONTACTS_DISPLAY": False,
"CONTACTS_CREATE": False,
"MAILBOXES_CREATE": False,
}
response = client.post(
"/api/v1.0/teams/",
{
@@ -54,6 +63,36 @@ def test_api_teams_create_authenticated():
assert team.accesses.filter(role="owner", user=user).exists()
def test_api_teams_create_authenticated_feature_disabled(settings):
"""
Authenticated users should not be able to create teams when feature is disabled.
"""
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
settings.FEATURES = {
"TEAMS_DISPLAY": True,
"TEAMS_CREATE": False,
"CONTACTS_DISPLAY": False,
"CONTACTS_CREATE": False,
"MAILBOXES_CREATE": False,
}
response = client.post(
"/api/v1.0/teams/",
{
"name": "my team",
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert not Team.objects.exists()
def test_api_teams_create_cannot_override_organization():
"""
Authenticated users should be able to create teams and not
+25 -4
View File
@@ -2,6 +2,8 @@
Test stats endpoint
"""
from django.core.cache import cache
import pytest
from rest_framework import status
from rest_framework.test import APIClient
@@ -14,17 +16,34 @@ from mailbox_manager import models as domains_models
pytestmark = pytest.mark.django_db
def test_api_stats__anonymous():
def test_api_stats__anonymous(django_assert_num_queries):
"""Stats endpoint should be available even when not connected."""
response = APIClient().get("/api/v1.0/stats/")
domains_factories.MailDomainFactory.create_batch(5)
core_factories.TeamFactory.create_batch(3)
# clear cache to allow stats count
cache.clear()
with django_assert_num_queries(5):
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_users": 0,
"mau": 0,
"domains": 0,
"domains": 5,
"mailboxes": 0,
"teams": 0,
"teams": 3,
}
# no new request made due to caching
with django_assert_num_queries(0):
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_users": 0,
"mau": 0,
"domains": 5,
"mailboxes": 0,
"teams": 3,
}
@@ -43,6 +62,8 @@ def test_api_stats__expected_count():
10, domain=domains_models.MailDomain.objects.all()[1]
)
# clear cache to allow stats count
cache.clear()
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@@ -248,7 +248,9 @@ def test_models_team_invitations_email():
assert len(mail.outbox) == 0
factories.TeamAccessFactory(team=team)
invitation = factories.InvitationFactory(team=team, email="john@people.com")
invitation = factories.InvitationFactory(
team=team, email="john@people.com", issuer__language="fr-fr"
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
@@ -257,10 +259,10 @@ def test_models_team_invitations_email():
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Desk!"
assert email.subject == "Invitation à rejoindre La Régie!"
email_content = " ".join(email.body.split())
assert "Invitation to join Desk!" in email_content
assert "Invitation à rejoindre La Régie!" in email_content
assert "[//example.com]" in email_content
@@ -56,7 +56,7 @@ def test_api_users_retrieve_me_authenticated():
"abilities": {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": False, "can_view": False},
"teams": {"can_create": False, "can_view": False},
"teams": {"can_create": True, "can_view": True},
},
"organization": {
"id": str(user.organization.pk),
@@ -66,11 +66,18 @@ def test_api_users_retrieve_me_authenticated():
}
def test_api_users_retrieve_me_authenticated_abilities():
def test_api_users_retrieve_me_authenticated_abilities(settings):
"""
Authenticated users should be able to retrieve their own user via the "/users/me" path
with the proper abilities.
"""
settings.FEATURES = {
"TEAMS_DISPLAY": False,
"TEAMS_CREATE": True,
"CONTACTS_DISPLAY": True,
"CONTACTS_CREATE": True,
"MAILBOXES_CREATE": True,
}
user = factories.UserFactory()
client = APIClient()
Binary file not shown.
+214 -164
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-09 11:42+0000\n"
"POT-Creation-Date: 2025-02-03 10:27+0000\n"
"PO-Revision-Date: 2024-01-03 23:15\n"
"Last-Translator: \n"
"Language-Team: French\n"
@@ -17,331 +17,359 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: core/admin.py:56
#: core/admin.py:63
msgid "Personal info"
msgstr ""
msgstr "Informations personnelles"
#: core/admin.py:58
#: core/admin.py:65
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:70
#: core/admin.py:77
msgid "Important dates"
msgstr ""
msgstr "Dates importantes"
#: core/admin.py:109
#: core/admin.py:116
msgid "User"
msgstr "Utilisateur"
#: core/admin.py:218
msgid "Run post creation plugins"
msgstr "Exécuter les plugins de post-création"
#: core/admin.py:226
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
"Les plugins de post-création ont été exécutés pour les organisations "
"sélectionnées."
#: core/authentication/backends.py:94
msgid "User info contained no recognizable user identification"
msgstr ""
"Les informations de l'utilisateur ne contiennent aucune identification "
"reconnaissable"
#: core/authentication/backends.py:116
msgid "User account is disabled"
msgstr ""
msgstr "Le compte de l'utilisateur est désactivé"
#: core/authentication/backends.py:162
msgid "Claims contained no recognizable user identification"
msgstr ""
"Les claims ne contiennent aucune identification reconnaissable pour "
"l'utilisateur"
#: core/authentication/backends.py:181
msgid "Claims contained no recognizable organization identification"
msgstr ""
"Les claims ne contiennent aucune identification reconnaissable pour "
"l'organisation"
#: core/enums.py:23
msgid "Failure"
msgstr ""
msgstr "En échec"
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:30
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr "En attente"
#: core/enums.py:25
msgid "Success"
msgstr ""
msgstr "Réussi"
#: core/models.py:46
#: core/models.py:47
msgid "Member"
msgstr ""
msgstr "Membre"
#: core/models.py:47 core/models.py:59 mailbox_manager/enums.py:14
#: core/models.py:48 core/models.py:60 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
msgstr "Administrateur"
#: core/models.py:48 mailbox_manager/enums.py:15
#: core/models.py:49 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: core/models.py:71
msgid "id"
msgstr ""
msgstr "Propriétaire"
#: core/models.py:72
msgid "primary key for the record as UUID"
msgstr ""
msgid "id"
msgstr "identifiant"
#: core/models.py:78
msgid "created at"
msgstr ""
#: core/models.py:73
msgid "primary key for the record as UUID"
msgstr "Clé primaire pour l'enregistrement en tant que UUID"
#: core/models.py:79
msgid "date and time at which a record was created"
msgstr ""
msgid "created at"
msgstr "Créé le"
#: core/models.py:84
msgid "updated at"
msgstr ""
#: core/models.py:80
msgid "date and time at which a record was created"
msgstr "Date et heure de création de l'enregistrement"
#: core/models.py:85
msgid "date and time at which a record was last updated"
msgstr ""
msgid "updated at"
msgstr "mis à jour le"
#: core/models.py:124
msgid "full name"
msgstr ""
#: core/models.py:86
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: core/models.py:125
msgid "full name"
msgstr "nom complet"
#: core/models.py:126
msgid "short name"
msgstr ""
msgstr "nom court"
#: core/models.py:128
#: core/models.py:129
msgid "notes"
msgstr ""
#: core/models.py:130
msgid "contact information"
msgstr ""
msgstr "notes"
#: core/models.py:131
msgid "A JSON object containing the contact information"
msgstr ""
msgid "contact information"
msgstr "informations de contact"
#: core/models.py:145
msgid "contact"
msgstr ""
#: core/models.py:132
msgid "A JSON object containing the contact information"
msgstr "Un objet JSON contenant les informations de contact"
#: core/models.py:146
msgid "contacts"
msgstr ""
msgid "contact"
msgstr "contact"
#: core/models.py:220 core/models.py:318 core/models.py:440
#: core/models.py:147
msgid "contacts"
msgstr "contacts"
#: core/models.py:221 core/models.py:319 core/models.py:441
#: mailbox_manager/models.py:24
msgid "name"
msgstr ""
msgstr "nom"
#: core/models.py:222
#: core/models.py:223
msgid "audience id"
msgstr ""
#: core/models.py:227
msgid "service provider"
msgstr ""
#: core/models.py:228
msgid "service provider"
msgstr "fournisseur de service"
#: core/models.py:229
msgid "service providers"
msgstr ""
msgstr "fournisseurs de service"
#: core/models.py:326
#: core/models.py:327
msgid "registration ID list"
msgstr ""
msgstr "liste d'identifiants d'inscription"
#: core/models.py:333
#: core/models.py:334
msgid "domain list"
msgstr ""
#: core/models.py:349
msgid "organization"
msgstr ""
msgstr "liste de domaines"
#: core/models.py:350
msgid "organizations"
msgstr ""
msgid "organization"
msgstr "organisation"
#: core/models.py:357
#: core/models.py:351
msgid "organizations"
msgstr "organisations"
#: core/models.py:358
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
"Une organisation doit avoir au moins un identifiant d'inscription ou un "
"domaine."
#: core/models.py:425
#: core/models.py:426
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez une sub valide. Cette valeur peut contenir uniquement des lettres, "
"des chiffres et des caractères @/./+/-/_"
#: core/models.py:431
#: core/models.py:432
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:433
#: core/models.py:434
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Requis. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ "
"uniquement."
#: core/models.py:439 core/models.py:844
#: core/models.py:440 core/models.py:880
msgid "email address"
msgstr ""
#: core/models.py:445
msgid "language"
msgstr ""
msgstr "adresse email"
#: core/models.py:446
msgid "language"
msgstr "langue"
#: core/models.py:447
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:452
#: core/models.py:453
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:455
#: core/models.py:456
msgid "device"
msgstr ""
msgstr "appareil"
#: core/models.py:457
#: core/models.py:458
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:460
#: core/models.py:461
msgid "staff status"
msgstr ""
#: core/models.py:462
#: core/models.py:463
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Si l'utilisateur peut se connecter à cette interface d'administration."
#: core/models.py:465
#: core/models.py:466
msgid "active"
msgstr ""
#: core/models.py:468
#: core/models.py:469
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:487
msgid "user"
msgstr ""
"Si cet utilisateur doit être considéré comme actif. Désélectionnez cette "
"option au lieu de supprimer les comptes."
#: core/models.py:488
msgid "users"
msgstr ""
msgid "user"
msgstr "utilisateur"
#: core/models.py:622
msgid "Organization/user relation"
msgstr ""
#: core/models.py:489
msgid "users"
msgstr "utilisateurs"
#: core/models.py:623
msgid "Organization/user relation"
msgstr "Relation entre une organisation et un utilisateur"
#: core/models.py:624
msgid "Organization/user relations"
msgstr ""
msgstr "Relations entre une organisation et un utilisateur"
#: core/models.py:628
#: core/models.py:629
msgid "This user is already in this organization."
msgstr ""
msgstr "Cet utilisateur est déjà dans cette organisation."
#: core/models.py:670
#: core/models.py:706
msgid "Team"
msgstr ""
msgstr "Equipe"
#: core/models.py:671
#: core/models.py:707
msgid "Teams"
msgstr ""
msgstr "Equipes"
#: core/models.py:722
#: core/models.py:758
msgid "Team/user relation"
msgstr ""
msgstr "Relation entre une équipe et un utilisateur"
#: core/models.py:723
#: core/models.py:759
msgid "Team/user relations"
msgstr ""
msgstr "Relations entre une équipe et un utilisateur"
#: core/models.py:728
#: core/models.py:764
msgid "This user is already in this team."
msgstr ""
msgstr "Cet utilisateur est déjà dans cette équipe."
#: core/models.py:817
#: core/models.py:853
msgid "url"
msgstr ""
msgstr "url"
#: core/models.py:818
#: core/models.py:854
msgid "secret"
msgstr ""
msgstr "secret"
#: core/models.py:827
#: core/models.py:863
msgid "Team webhook"
msgstr ""
msgstr "Webhook d'équipe"
#: core/models.py:828
#: core/models.py:864
msgid "Team webhooks"
msgstr ""
msgstr "Webhooks d'équipe"
#: core/models.py:861
#: core/models.py:897
msgid "Team invitation"
msgstr ""
msgstr "Invitation d'équipe"
#: core/models.py:862
#: core/models.py:898
msgid "Team invitations"
msgstr ""
msgstr "Invitations d'équipe"
#: core/models.py:887
#: core/models.py:923
msgid "This email is already associated to a registered user."
msgstr ""
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: core/models.py:929 core/models.py:935
msgid "Invitation to join Desk!"
msgstr ""
#: core/models.py:965 core/models.py:971
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie!"
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
msgstr "Logo de l'entreprise"
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
msgstr "Bonjour %(name)s"
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
msgstr "Bonjour"
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
msgstr "Merci beaucoup pour votre visite!"
#: core/templates/mail/html/hello.html:221
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
"Cette mail a été envoyée à %(email)s par <a href=\"%(href)s\">%(name)s</a>"
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
msgstr "La Suite Numérique"
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
msgstr "Invitation à rejoindre une équipe"
#: core/templates/mail/html/invitation.html:198
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to"
msgstr ""
msgstr "Bienvenue sur"
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
msgstr "Logo"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
"We are delighted to welcome you to our community on Régie, your new "
"We are delighted to welcome you to our community on La Régie, your new "
"companion to simplify the management of your groups efficiently, "
"intuitively, and securely."
msgstr ""
"Nous sommes ravis de vous accueillir dans notre communauté sur Régie, votre "
"nouveau compagnon pour simplifier la gestion de vos groupes de manière "
"efficace, intuitive et sécurisée."
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
@@ -349,21 +377,25 @@ msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
"Notre application est conçue pour vous aider à organiser, collaborer et "
"gérer les permissions."
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Régie, you will be able to:"
msgstr ""
msgid "With La Régie, you will be able to:"
msgstr "Avec La Régie, vous pourrez :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create customized groups according to your specific needs."
msgstr ""
"Créer des groupes personnalisés en fonction de vos besoins spécifiques."
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your team or community in just a few clicks."
msgstr ""
"Inviter des membres de votre équipe ou de votre communauté en quelques clics."
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
@@ -371,11 +403,15 @@ msgid ""
"Plan events, meetings, or activities effortlessly with our integrated "
"calendar."
msgstr ""
"Planifier des événements, des réunions ou des activités sans effort avec "
"notre calendrier intégré."
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/text/invitation.txt:20
msgid "Share documents, photos, and important information securely."
msgstr ""
"Partager des documents, des photos et des informations importantes de "
"manière sécurisée."
#: core/templates/mail/html/invitation.html:241
#: core/templates/mail/text/invitation.txt:21
@@ -383,18 +419,22 @@ msgid ""
"Facilitate exchanges and communication with our messaging and group "
"discussion tools."
msgstr ""
"Faciliter les échanges et la communication avec nos outils de messagerie et "
"de discussion de groupe."
#: core/templates/mail/html/invitation.html:252
#: core/templates/mail/text/invitation.txt:23
msgid "Visit Régie"
msgstr ""
msgid "Visit La Régie"
msgstr "Visiter La Régie"
#: core/templates/mail/html/invitation.html:261
#: core/templates/mail/text/invitation.txt:25
msgid ""
"We are confident that Régie will help you increase efficiency and "
"We are confident that La Régie will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
"Nous sommes convaincus que La Régie vous aidera à augmenter l'efficacité et "
"la productivité tout en renforçant le lien entre les membres."
#: core/templates/mail/html/invitation.html:266
#: core/templates/mail/text/invitation.txt:26
@@ -403,6 +443,10 @@ msgid ""
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
"N'hésitez pas à explorer toutes les fonctionnalités de l'application et à "
"partager vos commentaires et suggestions avec nous. Vos retours sont "
"précieux pour nous et nous permettront de continuer à améliorer notre "
"service."
#: core/templates/mail/html/invitation.html:271
#: core/templates/mail/text/invitation.txt:27
@@ -410,6 +454,8 @@ msgid ""
"Once again, welcome aboard! We are eager to accompany you on this group "
"management adventure."
msgstr ""
"Encore une fois, bienvenue parmi nous ! Nous sommes impatients de vous "
"accompagner dans cette aventure de gestion de groupe."
#: core/templates/mail/html/invitation.html:278
#: core/templates/mail/html/new_mailbox.html:272
@@ -471,21 +517,21 @@ msgstr "L'équipe de La Suite"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
msgstr "Cette mail a été envoyée à %(email)s par %(name)s [%(href)s]"
#: mailbox_manager/admin.py:13
msgid "Synchronise from dimail"
msgstr ""
msgstr "Synchroniser à partir de dimail"
#: mailbox_manager/admin.py:24
#, python-brace-format
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
msgstr ""
msgstr "Synchronisation échouée pour {domain.name} avec le message: [{err}]"
#: mailbox_manager/admin.py:30
#, python-brace-format
msgid "Synchronisation succeed for {domain.name}. "
msgstr ""
msgstr "Synchronisation réussie pour {domain.name}. "
#: mailbox_manager/admin.py:36
msgid "Check and update status from dimail"
@@ -520,65 +566,69 @@ msgstr ""
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
msgstr "Lecteur"
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:31
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr "Actif"
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:32
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En échec"
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:33
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr "Désactivé"
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr "Action requise"
#: mailbox_manager/models.py:35
msgid "Mail domain"
msgstr ""
msgstr "Domaine de messagerie"
#: mailbox_manager/models.py:36
msgid "Mail domains"
msgstr ""
msgstr "Domaines de messagerie"
#: mailbox_manager/models.py:102
msgid "User/mail domain relation"
msgstr ""
msgstr "Relation entre un utilisateur et un domaine de mail"
#: mailbox_manager/models.py:103
msgid "User/mail domain relations"
msgstr ""
msgstr "Relations entre un utilisateur et un domaine de mail"
#: mailbox_manager/models.py:175
msgid "local_part"
msgstr ""
msgstr "local_part"
#: mailbox_manager/models.py:189
msgid "secondary email address"
msgstr ""
msgstr "adresse email secondaire"
#: mailbox_manager/models.py:199
msgid "Mailbox"
msgstr ""
msgstr "Boîte mail"
#: mailbox_manager/models.py:200
msgid "Mailboxes"
msgstr ""
msgstr "Boîtes mails"
#: mailbox_manager/models.py:224
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
"Vous ne pouvez pas créer ou modifier une boîte mail pour un domain désactivé."
#: mailbox_manager/utils/dimail.py:262
#: mailbox_manager/utils/dimail.py:266
msgid "Your new mailbox information"
msgstr "Informations concernant votre nouvelle boîte mail"
#: people/settings.py:146
msgid "English"
msgstr ""
msgstr "Anglais"
#: people/settings.py:147
msgid "French"
msgstr ""
msgstr "Français"
+15 -1
View File
@@ -12,10 +12,17 @@ from mailbox_manager.utils.dimail import DimailAPIClient
@admin.action(description=_("Synchronise from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to synchronize existing mailboxes from dimail to our database."""
"""Admin action to synchronize existing mailboxes from dimail to our database.
Only works on enabled domains."""
excluded_domains = []
client = DimailAPIClient()
for domain in queryset:
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
try:
imported_mailboxes = client.import_mailboxes(domain)
except exceptions.HTTPError as err:
@@ -31,6 +38,13 @@ def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disabl
f"Imported mailboxes: {', '.join(imported_mailboxes)}"
),
)
if excluded_domains:
messages.warning(
request,
_(
f"Sync require enabled domains. Excluded domains: {', '.join(excluded_domains)}"
),
)
@admin.action(description=_("Check and update status from dimail"))
@@ -1,6 +1,5 @@
"""Client serializers for People's mailbox manager app."""
import json
from logging import getLogger
from requests.exceptions import HTTPError
@@ -36,23 +35,17 @@ class MailboxSerializer(serializers.ModelSerializer):
Override create function to fire a request on mailbox creation.
"""
mailbox = super().create(validated_data)
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
client = DimailAPIClient()
# send new mailbox request to dimail
response = client.create_mailbox(
validated_data, self.context["request"].user.sub
)
response = client.create_mailbox(mailbox, self.context["request"].user.sub)
# fix format to have actual json, and remove uuid
mailbox_data = json.loads(
response.content.decode("utf-8").replace("'", '"')
)
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox.save()
# send confirmation email
client.notify_mailbox_creation(
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
recipient=mailbox.secondary_email, mailbox_data=response.json()
)
# actually save mailbox on our database
@@ -63,6 +56,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
abilities = serializers.SerializerMethodField(read_only=True)
count_mailboxes = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomain
@@ -75,6 +69,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
"abilities",
"created_at",
"updated_at",
"count_mailboxes",
]
read_only_fields = [
"id",
@@ -83,6 +78,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
"abilities",
"created_at",
"updated_at",
"count_mailboxes",
]
def get_abilities(self, domain) -> dict:
@@ -92,6 +88,10 @@ class MailDomainSerializer(serializers.ModelSerializer):
return domain.get_abilities(request.user)
return {}
def get_count_mailboxes(self, domain) -> int:
"""Return count of mailboxes for the domain."""
return domain.mailboxes.count()
def create(self, validated_data):
"""
Override create function to fire a request to dimail upon domain creation.
+1
View File
@@ -22,6 +22,7 @@ class MailDomainStatusChoices(models.TextChoices):
ENABLED = "enabled", _("Enabled")
FAILED = "failed", _("Failed")
DISABLED = "disabled", _("Disabled")
ACTION_REQUIRED = "action_required", _("Action required")
class MailboxStatusChoices(models.TextChoices):
@@ -0,0 +1,58 @@
"""Management command to check and update domain status"""
import logging
from django.core.management.base import BaseCommand
import requests
from mailbox_manager.enums import MailDomainStatusChoices
from mailbox_manager.models import MailDomain
from mailbox_manager.utils.dimail import DimailAPIClient
logger = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Management command to check and update domains status from dimail
"""
help = (
"This command calls dimail to get and update the status of domains."
"All domains without a disabled status will be checked and updated if status"
"sent by dimail does not match our status saved in our database."
)
def handle(self, *args, **options):
"""Handling of the management command."""
self.stdout.write("Start fetching domain status from dimail...")
client = DimailAPIClient()
# do not fetch status of disabled domains
domains = MailDomain.objects.exclude(status=MailDomainStatusChoices.DISABLED)
for domain in domains:
old_status = domain.status
try:
client.fetch_domain_status(domain)
except requests.exceptions.HTTPError as err:
self.stdout.write(
self.style.ERROR(
f"Fetch failed for {domain.name} with message: '{err}'"
)
)
else:
action = "UPDATED" if old_status != domain.status else "CHECKED"
domain_name = (
f"{domain.name[:40]}..." if len(domain.name) > 40 else domain.name
)
self.stdout.write(
self.style.SUCCESS(
(
f"Domain {domain_name}"
+ "." * (50 - len(domain_name))
+ action
)
)
)
self.stdout.write("Done", ending="\n")
@@ -0,0 +1,19 @@
# Generated by Django 5.1.5 on 2025-01-31 17:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0015_change_mailboxes_status_to_enabled'),
]
operations = [
migrations.AlterField(
model_name='mailbox',
name='domain',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailboxes', to='mailbox_manager.maildomain'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-03 12:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0016_alter_mailbox_domain'),
]
operations = [
migrations.AlterField(
model_name='maildomain',
name='status',
field=models.CharField(choices=[('pending', 'Pending'), ('enabled', 'Enabled'), ('failed', 'Failed'), ('disabled', 'Disabled'), ('action_required', 'Action required')], default='pending', max_length=20),
),
]
+2 -2
View File
@@ -25,7 +25,7 @@ class MailDomain(BaseModel):
)
slug = models.SlugField(null=False, blank=False, unique=True, max_length=80)
status = models.CharField(
max_length=10,
max_length=20,
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
@@ -181,7 +181,7 @@ class Mailbox(BaseModel):
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
related_name="mail_domain",
related_name="mailboxes",
null=False,
blank=False,
)
@@ -114,6 +114,7 @@ def test_api_mail_domains__create_authenticated():
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 0,
}
# a new domain with status "pending" is created and authenticated user is the owner
@@ -185,6 +186,7 @@ def test_api_mail_domains__create_authenticated__dimail_failure():
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 0,
}
# a new domain with status "failed" is created and authenticated user is the owner
@@ -71,6 +71,7 @@ def test_api_mail_domains__retrieve_authenticated_related():
domain = factories.MailDomainEnabledFactory()
factories.MailDomainAccessFactory(domain=domain, user=user)
factories.MailboxFactory.create_batch(10, domain=domain)
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/",
@@ -85,4 +86,5 @@ def test_api_mail_domains__retrieve_authenticated_related():
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 10,
}
@@ -19,6 +19,10 @@ from core import factories as core_factories
from mailbox_manager import enums, factories, models
from mailbox_manager.api.client import serializers
from mailbox_manager.tests.fixtures.dimail import (
TOKEN_OK,
response_mailbox_created,
)
pytestmark = pytest.mark.django_db
@@ -102,19 +106,15 @@ def test_api_mailboxes__create_roles_success(role):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{mail_domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -161,19 +161,15 @@ def test_api_mailboxes__create_with_accent_success(role):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{mail_domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -287,7 +283,7 @@ def test_api_mailboxes__no_dimail_call_if_mailbox_creation_failed():
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -340,19 +336,15 @@ def test_api_mailboxes__same_local_part_on_different_domains():
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -485,7 +477,7 @@ def test_api_mailboxes__async_dimail_unauthorized(mock_error):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK, # user is in dimail-api
content_type="application/json",
)
@@ -538,19 +530,15 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsp = rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -657,7 +645,7 @@ def test_api_mailboxes__user_unrelated_to_domain():
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -702,7 +690,7 @@ def test_api_mailboxes__handling_dimail_unexpected_error(caplog):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -757,19 +745,15 @@ def test_api_mailboxes__send_correct_logger_infos(mock_info, mock_error):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -818,19 +802,15 @@ def test_api_mailboxes__sends_new_mailbox_notification(mock_info):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -0,0 +1,73 @@
"""Test the `fetch_domain_status_from_dimail` management command"""
import json
import re
from io import StringIO
from django.core.management import call_command
import pytest
import responses
from rest_framework import status
from mailbox_manager import enums, factories
from mailbox_manager.tests.fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
)
pytestmark = pytest.mark.django_db
@responses.activate
def test_fetch_domain_status():
"""Test fetch domain status from dimail"""
domain_enabled1 = factories.MailDomainEnabledFactory()
domain_enabled2 = factories.MailDomainEnabledFactory()
domain_disabled = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.DISABLED
)
domain_failed = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.FAILED
)
body_content_ok1 = CHECK_DOMAIN_OK.copy()
body_content_ok1["name"] = domain_enabled1.name
body_content_broken = CHECK_DOMAIN_BROKEN.copy()
body_content_broken["name"] = domain_enabled2.name
body_content_ok2 = CHECK_DOMAIN_OK.copy()
body_content_ok2["name"] = domain_disabled.name
body_content_ok3 = CHECK_DOMAIN_OK.copy()
body_content_ok3["name"] = domain_failed.name
for domain, body_content in [
(domain_enabled1, body_content_ok1),
(domain_enabled2, body_content_broken),
(domain_failed, body_content_ok3),
]:
# mock dimail API
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
output = StringIO()
call_command("fetch_domain_status", verbosity=2, stdout=output)
domain_enabled1.refresh_from_db()
domain_enabled2.refresh_from_db()
domain_disabled.refresh_from_db()
domain_failed.refresh_from_db()
# nothing change for the first domain enable
assert domain_enabled1.status == enums.MailDomainStatusChoices.ENABLED
# status of the second activated domain has changed to action required
assert domain_enabled2.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
# status of the failed domain has changed to enabled
assert domain_failed.status == enums.MailDomainStatusChoices.ENABLED
# disabled domain was excluded
assert domain_disabled.status == enums.MailDomainStatusChoices.DISABLED
assert output.getvalue().count("CHECKED") == 1
assert output.getvalue().count("UPDATED") == 2
+165 -3
View File
@@ -1,6 +1,10 @@
# pylint: disable=line-too-long
"""Define here some fake data from dimail, useful to mock dimail response"""
import json
## DOMAINS
CHECK_DOMAIN_BROKEN = {
"name": "example.fr",
"state": "broken",
@@ -12,7 +16,11 @@ CHECK_DOMAIN_BROKEN = {
"smtp_domain": None,
"context_name": "example.fr",
"transport": None,
"domain_exist": {"ok": True, "internal": False, "errors": []},
"domain_exist": {
"ok": True,
"internal": False,
"errors": [],
},
"mx": {
"ok": False,
"internal": False,
@@ -70,8 +78,16 @@ CHECK_DOMAIN_BROKEN = {
{"code": "no_dkim", "detail": "Il faut un DKIM record, avec la bonne clef"}
],
},
"postfix": {"ok": True, "internal": True, "errors": []},
"ox": {"ok": True, "internal": True, "errors": []},
"postfix": {
"ok": True,
"internal": True,
"errors": [],
},
"ox": {
"ok": True,
"internal": True,
"errors": [],
},
"cert": {
"ok": False,
"internal": True,
@@ -81,6 +97,141 @@ CHECK_DOMAIN_BROKEN = {
},
}
CHECK_DOMAIN_BROKEN_INTERNAL = {
"name": "example.fr",
"state": "broken",
"valid": False,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"webmail_domain": None,
"imap_domain": None,
"smtp_domain": None,
"context_name": "example.fr",
"transport": None,
"domain_exist": {
"ok": True,
"internal": False,
"errors": [],
},
"mx": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_imap": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_smtp": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_webmail": {
"ok": True,
"internal": False,
"errors": [],
},
"spf": {
"ok": True,
"internal": False,
"errors": [],
},
"dkim": {
"ok": True,
"internal": False,
"errors": [],
},
"postfix": {
"ok": True,
"internal": True,
"errors": [],
},
"ox": {
"ok": True,
"internal": True,
"errors": [],
},
"cert": {
"ok": False,
"internal": True,
"errors": [
{"code": "no_cert", "detail": "Pas de certificat pour ce domaine (ls)"}
],
},
}
CHECK_DOMAIN_BROKEN_EXTERNAL = {
"name": "example.fr",
"state": "broken",
"valid": False,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"webmail_domain": None,
"imap_domain": None,
"smtp_domain": None,
"context_name": "example.fr",
"transport": None,
"domain_exist": {
"ok": True,
"internal": False,
"errors": [],
},
"mx": {
"ok": False,
"internal": False,
"errors": [
{
"code": "wrong_mx",
"detail": "Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr., or je trouve example-fr.mail.protection.outlook.com.",
}
],
},
"cname_imap": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_smtp": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_webmail": {
"ok": True,
"internal": False,
"errors": [],
},
"spf": {
"ok": True,
"internal": False,
"errors": [],
},
"dkim": {
"ok": True,
"internal": False,
"errors": [],
},
"postfix": {
"ok": True,
"internal": True,
"errors": [],
},
"ox": {
"ok": True,
"internal": True,
"errors": [],
},
"cert": {
"ok": True,
"internal": True,
"errors": [],
},
}
CHECK_DOMAIN_OK = {
"name": "example.fr",
"state": "ok",
@@ -103,3 +254,14 @@ CHECK_DOMAIN_OK = {
"ox": {"ok": True, "internal": True, "errors": []},
"cert": {"ok": True, "internal": True, "errors": []},
}
## TOKEN
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
## MAILBOXES
def response_mailbox_created(email_address):
"""mimic dimail response upon succesfull mailbox creation."""
return json.dumps({"email": email_address, "password": "password"})
@@ -9,16 +9,55 @@ from django.urls import reverse
import pytest
import responses
from rest_framework import status
from core import factories as core_factories
from mailbox_manager import enums, factories
from mailbox_manager import enums, factories, models
from .fixtures.dimail import CHECK_DOMAIN_BROKEN, CHECK_DOMAIN_OK
from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
TOKEN_OK,
response_mailbox_created,
)
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.FAILED,
enums.MailDomainStatusChoices.DISABLED,
],
)
@pytest.mark.django_db
def test_admin_action__fetch_domain_status_from_dimail(client):
def test_sync_mailboxes__should_not_sync_if_domain_is_not_enabled(
domain_status, client
):
"""Mailboxes should not be sync'ed on non-enabled domains."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=domain_status)
data = {
"action": "sync_mailboxes_from_dimail",
"_selected_action": [domain.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
with responses.RequestsMock():
# No call expected
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
assert (
f"Sync require enabled domains. Excluded domains: {domain}"
in response.content.decode("utf-8")
)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client):
"""Test admin action to check health of some domains"""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -32,48 +71,82 @@ def test_admin_action__fetch_domain_status_from_dimail(client):
],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain1.name
body_content_domain2 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain2["name"] = domain2.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain2.name}/check/"),
body=json.dumps(body_content_domain2),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
domain1.refresh_from_db()
domain2.refresh_from_db()
assert domain1.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain2.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert "Check domains done with success" in response.content.decode("utf-8")
with responses.RequestsMock() as rsps:
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain1.name
body_content_domain2 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain2["name"] = domain2.name
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
status=200,
content_type="application/json",
)
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain2.name}/check/"),
body=json.dumps(body_content_domain2),
status=200,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == 200
domain1.refresh_from_db()
domain2.refresh_from_db()
assert domain1.status == enums.MailDomainStatusChoices.FAILED
assert domain2.status == enums.MailDomainStatusChoices.FAILED
assert "Check domains done with success" in response.content.decode("utf-8")
# check with a valid domain info from dimail
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain1.name
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
status=200,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == 200
domain1.refresh_from_db()
domain2.refresh_from_db()
assert domain1.status == enums.MailDomainStatusChoices.ENABLED
assert domain2.status == enums.MailDomainStatusChoices.FAILED
assert "Check domains done with success" in response.content.decode("utf-8")
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
"""Test admin action should switch domain state to ENABLED
when dimail's response is "ok". It should also activate any pending mailbox."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain1 = factories.MailDomainFactory()
factories.MailboxFactory.create_batch(3, domain=domain1)
domain2 = factories.MailDomainFactory()
data = {
"action": "fetch_domain_status_from_dimail",
"_selected_action": [domain1.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain1.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
status=status.HTTP_200_OK,
content_type="application/json",
)
# we need to get a token to create mailboxes
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
body=response_mailbox_created(f"truc@{domain1.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
domain1.refresh_from_db()
domain2.refresh_from_db()
assert domain1.status == enums.MailDomainStatusChoices.ENABLED
assert domain2.status == enums.MailDomainStatusChoices.PENDING
assert "Check domains done with success" in response.content.decode("utf-8")
for mailbox in models.Mailbox.objects.filter(domain=domain1):
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
@@ -3,6 +3,7 @@ Unit tests for dimail client
"""
import json
import logging
import re
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from logging import Logger
@@ -15,7 +16,14 @@ from rest_framework import status
from mailbox_manager import enums, factories, models
from mailbox_manager.utils.dimail import DimailAPIClient
from .fixtures.dimail import CHECK_DOMAIN_BROKEN, CHECK_DOMAIN_OK
from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_BROKEN_EXTERNAL,
CHECK_DOMAIN_BROKEN_INTERNAL,
CHECK_DOMAIN_OK,
TOKEN_OK,
response_mailbox_created,
)
pytestmark = pytest.mark.django_db
@@ -156,37 +164,240 @@ def test_dimail_synchronization__synchronize_mailboxes(mock_warning):
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == "oxadmin"
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
assert imported_mailboxes == [mailbox_valid["email"]]
def test_dimail__fetch_domain_status_from_dimail():
"""Request to dimail health status of a domain"""
domain = factories.MailDomainEnabledFactory()
with responses.RequestsMock() as rsps:
body_content = CHECK_DOMAIN_BROKEN.copy()
body_content["name"] = domain.name
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
response = dimail_client.fetch_domain_status(domain)
assert response.status_code == status.HTTP_200_OK
assert domain.status == enums.MailDomainStatusChoices.FAILED
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.ACTION_REQUIRED,
enums.MailDomainStatusChoices.FAILED,
enums.MailDomainStatusChoices.ENABLED,
],
)
@responses.activate
def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
"""Domains should be enabled when dimail check returns ok status"""
domain = factories.MailDomainFactory(status=domain_status)
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
# Now domain is ok again
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
# call again, should be ok
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.ENABLED,
enums.MailDomainStatusChoices.ACTION_REQUIRED,
enums.MailDomainStatusChoices.FAILED,
],
)
@responses.activate
def test_dimail__fetch_domain_status__switch_to_action_required(
domain_status,
):
"""Domains should be in status action required when dimail check
returns broken status for external checks."""
domain = factories.MailDomainFactory(status=domain_status)
body_content = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
# Support team fixes their part of the problem
# Now domain is OK again
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.ENABLED,
enums.MailDomainStatusChoices.ACTION_REQUIRED,
],
)
@responses.activate
def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
"""Domains should be in status failed when dimail check returns broken status
for only internal checks dispite a fix call."""
domain = factories.MailDomainFactory(status=domain_status)
# nothing can be done by support team, domain should be in failed
body_content = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and still returns KO for internal checks
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.FAILED
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.ENABLED,
enums.MailDomainStatusChoices.ACTION_REQUIRED,
],
)
@responses.activate
def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
"""Domains should be enabled when dimail check returns ok status
after a fix call."""
domain = factories.MailDomainFactory(status=domain_status)
# with all checks KO, domain should be in action required
body_content = CHECK_DOMAIN_BROKEN.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
# We assume that the support has fixed their part.
# So now dimail returns OK for external checks but still KO for internal checks.
# A call to dimail fix endpoint is necessary and will be done by
# the fetch_domain_status call
body_content = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and returns OK. Hooray!
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
def test_dimail__enable_pending_mailboxes(caplog):
"""Status of pending mailboxes should switch to "enabled"
when calling enable_pending_mailboxes."""
caplog.set_level(logging.INFO)
domain = factories.MailDomainFactory()
mailbox1 = factories.MailboxFactory(
domain=domain, status=enums.MailboxStatusChoices.PENDING
)
mailbox2 = factories.MailboxFactory(
domain=domain, status=enums.MailboxStatusChoices.PENDING
)
factories.MailboxFactory.create_batch(
2, domain=domain, status=enums.MailboxStatusChoices.ENABLED
)
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
response = dimail_client.fetch_domain_status(domain)
assert response.status_code == status.HTTP_200_OK
assert domain.status == enums.MailDomainStatusChoices.ENABLED
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.enable_pending_mailboxes(domain=domain)
mailbox1.refresh_from_db()
mailbox2.refresh_from_db()
assert mailbox1.status == enums.MailboxStatusChoices.ENABLED
assert mailbox2.status == enums.MailboxStatusChoices.ENABLED
assert len(caplog.records) == 6
assert (
caplog.records[0].message
== "Token succesfully granted by mail-provisioning API."
)
assert (
caplog.records[1].message
== f"Mailbox successfully created on domain {domain.name} by user None"
)
assert (
caplog.records[2].message
== f"Information for mailbox mock@{domain.name} sent to {mailbox1.secondary_email}."
)
assert (
caplog.records[4].message
== f"Mailbox successfully created on domain {domain.name} by user None"
)
assert (
caplog.records[5].message
== f"Information for mailbox mock@{domain.name} sent to {mailbox2.secondary_email}."
)
+96 -25
View File
@@ -116,15 +116,15 @@ class DimailAPIClient:
"""Send a CREATE mailbox request to mail provisioning API."""
payload = {
"givenName": mailbox["first_name"],
"surName": mailbox["last_name"],
"displayName": f"{mailbox['first_name']} {mailbox['last_name']}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
headers = self.get_headers(user_sub)
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox['domain']}/mailboxes/{mailbox['local_part']}",
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}",
json=payload,
headers=headers,
verify=True,
@@ -141,7 +141,7 @@ class DimailAPIClient:
if response.status_code == status.HTTP_201_CREATED:
logger.info(
"Mailbox successfully created on domain %s by user %s",
str(mailbox["domain"]),
str(mailbox.domain),
user_sub,
)
return response
@@ -149,7 +149,7 @@ class DimailAPIClient:
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(mailbox["domain"]),
str(mailbox.domain),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
@@ -337,10 +337,10 @@ class DimailAPIClient:
last_name=dimail_mailbox["surName"],
local_part=address.username,
domain=domain,
secondary_email=dimail_mailbox[
"email"
], # secondary email is mandatory. Unfortunately, dimail doesn't
secondary_email=dimail_mailbox["email"],
# secondary email is mandatory. Unfortunately, dimail doesn't
# store any. We temporarily give current email as secondary email.
status=enums.MailboxStatusChoices.ENABLED,
)
imported_mailboxes.append(str(mailbox))
else:
@@ -399,8 +399,25 @@ class DimailAPIClient:
return response
return self.raise_exception_for_unexpected_response(response)
def fetch_domain_status(self, domain):
"""Send a request to check domain and update status of our domain."""
def enable_pending_mailboxes(self, domain):
"""Send requests for all pending mailboxes of a domain."""
for mailbox in domain.mailboxes.filter(
status=enums.MailboxStatusChoices.PENDING
):
response = self.create_mailbox(mailbox)
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox.save()
# send confirmation email
self.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=response.json(),
)
def check_domain(self, domain):
"""Send a request to dimail to check domain health."""
response = session.get(
f"{self.API_URL}/domains/{domain.name}/check/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
@@ -408,18 +425,72 @@ class DimailAPIClient:
timeout=10,
)
if response.status_code == status.HTTP_200_OK:
dimail_status = response.json()["state"]
if (
domain.status != enums.MailDomainStatusChoices.ENABLED
and dimail_status == "ok"
):
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
elif (
domain.status != enums.MailDomainStatusChoices.FAILED
and dimail_status == "broken"
):
domain.status = enums.MailDomainStatusChoices.FAILED
domain.save()
return response
return response.json()
return self.raise_exception_for_unexpected_response(response)
def fix_domain(self, domain):
"""Send a request to dimail to fix a domain.
Allow to fix internal checks."""
response = session.get(
f"{self.API_URL}/domains/{domain.name}/fix/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
verify=True,
timeout=10,
)
if response.status_code == status.HTTP_200_OK:
logger.info(
"Domain %s successfully fixed by dimail",
str(domain),
)
return response.json()
return self.raise_exception_for_unexpected_response(response)
def fetch_domain_status(self, domain):
"""Send a request to check and update status of a domain."""
dimail_response = self.check_domain(domain)
dimail_state = dimail_response["state"]
# if domain is not enabled and dimail returns ok status, enable it
if (
domain.status != enums.MailDomainStatusChoices.ENABLED
and dimail_state == "ok"
):
self.enable_pending_mailboxes(domain)
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
# if dimail returns broken status, we need to extract external and internal checks
# and manage the case where the domain has to be fixed by support
elif dimail_state == "broken":
external_checks = self._get_dimail_checks(dimail_response, internal=False)
internal_checks = self._get_dimail_checks(dimail_response, internal=True)
# manage the case where the domain has to be fixed by support
if not all(external_checks.values()):
domain.status = enums.MailDomainStatusChoices.ACTION_REQUIRED
domain.save()
# if all external checks are ok but not internal checks, we need to fix internal checks
elif all(external_checks.values()) and not all(internal_checks.values()):
# a call to fix endpoint is needed because all external checks are ok
dimail_response = self.fix_domain(domain)
# we need to check again if all internal and external checks are ok
external_checks = self._get_dimail_checks(
dimail_response, internal=False
)
internal_checks = self._get_dimail_checks(
dimail_response, internal=True
)
if all(external_checks.values()) and all(internal_checks.values()):
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
elif all(external_checks.values()) and not all(
internal_checks.values()
):
domain.status = enums.MailDomainStatusChoices.FAILED
domain.save()
return dimail_response
def _get_dimail_checks(self, dimail_response, internal: bool):
checks = {
key: value
for key, value in dimail_response.items()
if isinstance(value, dict) and value.get("internal") is internal
}
return {key: value.get("ok", False) for key, value in checks.items()}
+10 -15
View File
@@ -24,27 +24,22 @@ class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin):
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
@staticmethod
def _extract_name_from_organization_data(organization_data):
"""Extract the name from the organization data."""
try:
return organization_data["liste_enseignes"][0].title()
except KeyError:
logger.warning("Missing key 'liste_enseignes' in %s", organization_data)
except IndexError:
logger.warning("Empty list 'liste_enseignes' in %s", organization_data)
return None
def get_organization_name_from_results(self, data, siret):
def get_organization_name_from_results(data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
nature = "nature_juridique"
commune = nature in result and result[nature] == "7210"
is_commune = (
result.get("nature_juridique") == "7210"
) # INSEE code for commune
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
if commune:
if is_commune:
return organization["libelle_commune"].title()
return self._extract_name_from_organization_data(organization)
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title()
if name := result.get("nom_raison_sociale"):
return name.title()
logger.warning("No organization name found for SIRET %s", siret)
return None
@@ -168,3 +168,43 @@ def test_extract_name_from_org_data_when_commune(
plugin = NameFromSiretOrganizationPlugin()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
@responses.activate
def test_organization_plugins_run_after_create_no_list_enseignes(
organization_plugins_settings,
):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(
responses.GET,
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={
"results": [
{
"nom_raison_sociale": "AMAZING ORGANIZATION",
# skipping some fields
"matching_etablissements": [
# skipping some fields
{
"liste_enseignes": None,
"siret": "12345678901234",
}
],
}
],
"total_results": 1,
"page": 1,
"per_page": 10,
"total_pages": 1,
},
status=200,
)
organization = Organization.objects.create(
name="12345678901234", registration_id_list=["12345678901234"]
)
assert organization.name == "Amazing Organization"
# Check that the organization has been updated in the database also
organization.refresh_from_db()
assert organization.name == "Amazing Organization"
+8 -8
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.10.0"
version = "1.10.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.36.2",
"boto3==1.36.11",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -42,13 +42,13 @@ dependencies = [
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"joserfc==1.0.1",
"joserfc==1.0.2",
"requests==2.32.3",
"sentry-sdk[django]==2.20.0",
"whitenoise==6.8.2",
@@ -64,20 +64,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.12.1",
"drf-spectacular-sidecar==2025.2.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"ipython==8.32.0",
"jq==1.8.0",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.3",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.2",
"ruff==0.9.4",
"types-requests==2.32.0.20241016",
"freezegun==1.5.1",
]
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+10 -10
View File
@@ -18,16 +18,16 @@
"@gouvfr-lasuite/integration": "1.0.2",
"@hookform/resolvers": "3.10.0",
"@openfun/cunningham-react": "3.0.0",
"@tanstack/react-query": "5.64.2",
"i18next": "24.2.1",
"@tanstack/react-query": "5.66.0",
"i18next": "24.2.2",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.5",
"next": "15.1.6",
"react": "*",
"react-dom": "*",
"react-hook-form": "7.54.2",
"react-i18next": "15.4.0",
"react-select": "5.9.0",
"react-select": "5.10.0",
"sass": "1.83.4",
"styled-components": "6.1.14",
"zod": "3.24.1",
@@ -36,16 +36,16 @@
"devDependencies": {
"@hookform/devtools": "4.3.3",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.64.2",
"@tanstack/react-query-devtools": "5.66.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.2.0",
"@testing-library/user-event": "14.6.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.14",
"@types/lodash": "4.17.15",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "19.0.7",
"@types/react": "19.0.8",
"@types/react-dom": "*",
"dotenv": "16.4.7",
"eslint-config-people": "*",
@@ -54,9 +54,9 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.4.2",
"stylelint": "16.13.2",
"stylelint": "16.14.1",
"stylelint-config-standard": "37.0.0",
"stylelint-prettier": "5.0.2",
"stylelint-prettier": "5.0.3",
"typescript": "*"
}
}
@@ -110,6 +110,10 @@ describe('ModalAddMailDomain', () => {
await user.click(buttonSubmit);
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.lastUrl()).toContain('/mail-domains/');
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
@@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { useAuthStore } from '@/core/auth';
import { AppWrapper } from '@/tests/utils';
import { Panel } from '../components/Panel';
@@ -23,6 +24,19 @@ describe('PanelTeams', () => {
});
it('renders with no team to display', async () => {
useAuthStore.setState({
userData: {
id: '1',
email: 'test@example.com',
name: 'Test User',
abilities: {
teams: { can_view: true, can_create: true },
mailboxes: { can_view: true },
contacts: { can_view: true },
},
},
});
fetchMock.mock(`end:/teams/?ordering=-created_at`, []);
render(<TeamList />, { wrapper: AppWrapper });
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import IconAdd from '@/assets/icons/icon-add.svg';
import IconSort from '@/assets/icons/icon-sort.svg';
import { Box, BoxButton, StyledLink } from '@/components';
import { useAuthStore } from '@/core/auth';
import { useCunninghamTheme } from '@/cunningham';
import { TeamsOrdering } from '@/features/teams/team-management/api';
@@ -13,6 +14,9 @@ export const PanelActions = () => {
const { t } = useTranslation();
const { changeOrdering, ordering } = useTeamStore();
const { colorsTokens } = useCunninghamTheme();
const { userData } = useAuthStore();
const can_create = userData?.abilities?.teams.can_create ?? false;
const isSortAsc = ordering === TeamsOrdering.BY_CREATED_ON;
@@ -43,17 +47,19 @@ export const PanelActions = () => {
>
<IconSort width={30} height={30} aria-hidden="true" />
</BoxButton>
<StyledLink href="/teams/create">
<BoxButton
as="span"
$margin={{ all: 'auto' }}
aria-label={t('Add a team')}
$color={colorsTokens()['primary-600']}
tabIndex={-1}
>
<IconAdd width={27} height={27} aria-hidden="true" />
</BoxButton>
</StyledLink>
{can_create && (
<StyledLink href="/teams/create">
<BoxButton
as="span"
$margin={{ all: 'auto' }}
aria-label={t('Add a team')}
$color={colorsTokens()['primary-600']}
tabIndex={-1}
>
<IconAdd width={27} height={27} aria-hidden="true" />
</BoxButton>
</StyledLink>
)}
</Box>
);
};
@@ -3,6 +3,7 @@ import React, { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Team, useTeams } from '@/features/teams/team-management';
import { useTeamStore } from '../store';
@@ -17,6 +18,9 @@ interface PanelTeamsStateProps {
const TeamListState = ({ isLoading, isError, teams }: PanelTeamsStateProps) => {
const { t } = useTranslation();
const { userData } = useAuthStore();
const can_create = userData?.abilities?.teams.can_create ?? false;
if (isError) {
return (
@@ -42,11 +46,13 @@ const TeamListState = ({ isLoading, isError, teams }: PanelTeamsStateProps) => {
<Text as="p" $margin={{ vertical: 'none' }}>
{t('0 group to display.')}
</Text>
<Text as="p">
{t(
'Create your first team by clicking on the "Create a new team" button.',
)}
</Text>
{can_create && (
<Text as="p">
{t(
'Create your first team by clicking on the "Create a new team" button.',
)}
</Text>
)}
</Box>
);
}
@@ -39,9 +39,18 @@ test.describe('Config', () => {
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-member');
await expect(page.locator('menu')).toBeHidden();
await page
.locator('menu')
.first()
.getByLabel(`Mail Domains button`)
.click();
await expect(page).toHaveURL(/mail-domains\//);
await expect(page.getByText('Mail Domains')).toBeVisible();
await expect(
page
.getByLabel('Mail domains panel', { exact: true })
.getByText('Mail Domains'),
).toBeVisible();
});
test('it checks that the user abilities display teams', async ({
@@ -60,12 +69,6 @@ test.describe('Config', () => {
page,
browserName,
}) => {
await page.goto('/');
// Login with a user who has the visibility on the groups should see groups
// It's now the backend that decides if the user can see the group menu and they
// should be redirected to the groups page in such case
await keyCloakSignIn(page, browserName, 'team-administrator');
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
@@ -83,6 +86,12 @@ test.describe('Config', () => {
}
});
await page.goto('/');
// Login with a user who has the visibility on the groups should see groups
// It's now the backend that decides if the user can see the group menu and they
// should be redirected to the groups page in such case
await keyCloakSignIn(page, browserName, 'team-administrator');
await expect(page.locator('menu')).toBeHidden();
await expect(page.getByText('Groups')).toBeVisible();
@@ -25,9 +25,9 @@ const payloadGetTeams = {
],
};
const mockApiRequests = (page: Page) => {
void page.route('**/teams/?ordering=-created_at', (route) => {
void route.fulfill({
const mockApiRequests = async (page: Page) => {
await page.route('**/teams/?ordering=-created_at', async (route) => {
await route.fulfill({
json: payloadGetTeams.results,
});
});
@@ -40,11 +40,11 @@ test.describe('Keyboard navigation', () => {
}) => {
const page = await browser.newPage();
await mockApiRequests(page);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'team-owner-mail-member');
void mockApiRequests(page);
const header = page.locator('header');
// La Gauffre button is loaded asynchronously, so we wait for it to be visible
@@ -85,10 +85,13 @@ const mailboxesFixtures = {
},
};
const interceptCommonApiRequests = (page: Page, mailDomains?: MailDomain[]) => {
const interceptCommonApiRequests = async (
page: Page,
mailDomains?: MailDomain[],
) => {
const mailDomainsToUse = mailDomains ?? mailDomainsFixtures;
void page.route('**/api/v1.0/mail-domains/?page=*', (route) => {
void route.fulfill({
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
await route.fulfill({
json: {
count: mailDomainsToUse.length,
next: null,
@@ -98,28 +101,37 @@ const interceptCommonApiRequests = (page: Page, mailDomains?: MailDomain[]) => {
});
});
mailDomainsToUse.forEach((mailDomain) => {
void page.route(`**/api/v1.0/mail-domains/${mailDomain.slug}/`, (route) => {
void route.fulfill({
json: mailDomain,
});
});
await Promise.all(
mailDomainsToUse.map(async (mailDomain) => {
await page.route(
`**/api/v1.0/mail-domains/${mailDomain.slug}/`,
async (route) => {
await route.fulfill({
json: mailDomain,
});
},
);
}),
);
void page.route(
`**/api/v1.0/mail-domains/${mailDomain.slug}/mailboxes/?page=1**`,
(route) => {
void route.fulfill({
json: {
count: mailboxesFixtures.domainFr.page1.length,
next: null,
previous: null,
results: mailboxesFixtures.domainFr.page1,
},
});
},
{ times: 1 },
);
});
await Promise.all(
mailDomainsToUse.map(async (mailDomain) => {
await page.route(
`**/api/v1.0/mail-domains/${mailDomain.slug}/mailboxes/?page=1**`,
async (route) => {
await route.fulfill({
json: {
count: mailboxesFixtures.domainFr.page1.length,
next: null,
previous: null,
results: mailboxesFixtures.domainFr.page1,
},
});
},
{ times: 1 },
);
}),
);
};
const navigateToMailboxCreationFormForMailDomainFr = async (
@@ -131,19 +143,20 @@ const navigateToMailboxCreationFormForMailDomainFr = async (
};
test.describe('Mail domain create mailbox', () => {
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
// Login with a user who has the visibility on the mail domains
await keyCloakSignIn(page, browserName, 'mail-member');
});
test('checks create mailbox button is visible or not', async ({ page }) => {
test('checks create mailbox button is visible or not', async ({
page,
browserName,
}) => {
const domains = [...mailDomainsFixtures];
domains[0].status = 'enabled';
domains[1].status = 'pending';
domains[2].status = 'disabled';
domains[3].status = 'failed';
void interceptCommonApiRequests(page, domains);
await interceptCommonApiRequests(page, domains);
await page.goto('/');
// Login with a user who has the visibility on the mail domains
await keyCloakSignIn(page, browserName, 'mail-member');
await page
.locator('menu')
@@ -190,6 +203,7 @@ test.describe('Mail domain create mailbox', () => {
test('checks user can create a mailbox when he has post ability', async ({
page,
browserName,
}) => {
const newMailbox = {
id: '04433733-c9b7-453a-8122-755ac115bb00',
@@ -197,13 +211,13 @@ test.describe('Mail domain create mailbox', () => {
secondary_email: 'john.doe-complex2024@mail.com',
};
const interceptRequests = (page: Page) => {
void interceptCommonApiRequests(page);
const interceptRequests = async (page: Page) => {
await interceptCommonApiRequests(page);
void page.route(
await page.route(
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1**',
(route) => {
void route.fulfill({
async (route) => {
await route.fulfill({
json: {
count: [...mailboxesFixtures.domainFr.page1, newMailbox].length,
next: null,
@@ -214,15 +228,15 @@ test.describe('Mail domain create mailbox', () => {
},
);
void page.route(
await page.route(
'**/api/v1.0/mail-domains/domainfr/mailboxes/',
(route) => {
async (route) => {
if (route.request().method() === 'POST') {
void route.fulfill({
await route.fulfill({
json: newMailbox,
});
} else {
void route.continue();
await route.continue();
}
},
);
@@ -249,8 +263,12 @@ test.describe('Mail domain create mailbox', () => {
}
});
void interceptRequests(page);
expect(true).toBeTruthy();
await interceptRequests(page);
await page.goto('/');
// Login with a user who has the visibility on the mail domains
await keyCloakSignIn(page, browserName, 'mail-member');
await navigateToMailboxCreationFormForMailDomainFr(page);
await page.getByRole('button', { name: 'Cancel' }).click();
@@ -315,15 +333,16 @@ test.describe('Mail domain create mailbox', () => {
test('checks user is not allowed to create a mailbox when he is missing post ability', async ({
page,
browserName,
}) => {
const localMailDomainsFixtures = [...mailDomainsFixtures];
localMailDomainsFixtures[0].abilities.post = false;
const localMailDomainDomainFr = localMailDomainsFixtures[0];
const localMailboxFixtures = { ...mailboxesFixtures };
const interceptRequests = (page: Page) => {
void page.route('**/api/v1.0/mail-domains/?page=*', (route) => {
void route.fulfill({
const interceptRequests = async (page: Page) => {
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
await route.fulfill({
json: {
count: localMailDomainsFixtures.length,
next: null,
@@ -333,16 +352,16 @@ test.describe('Mail domain create mailbox', () => {
});
});
void page.route('**/api/v1.0/mail-domains/domainfr/', (route) => {
void route.fulfill({
await page.route('**/api/v1.0/mail-domains/domainfr/', async (route) => {
await route.fulfill({
json: localMailDomainDomainFr,
});
});
void page.route(
await page.route(
'**/api/v1.0/mail-domains/domainfr/mailboxes/?page=1**',
(route) => {
void route.fulfill({
async (route) => {
await route.fulfill({
json: {
count: localMailboxFixtures.domainFr.page1.length,
next: null,
@@ -355,7 +374,11 @@ test.describe('Mail domain create mailbox', () => {
);
};
void interceptRequests(page);
await interceptRequests(page);
await page.goto('/');
// Login with a user who has the visibility on the mail domains
await keyCloakSignIn(page, browserName, 'mail-member');
await page
.locator('menu')
@@ -142,11 +142,6 @@ test.describe('Mail domain', () => {
});
test.describe('user is administrator or owner', () => {
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-owner');
});
test.describe('mail domain is enabled', () => {
const mailDomainsFixtures: MailDomain[] = [
{
@@ -215,9 +210,12 @@ test.describe('Mail domain', () => {
},
];
test('checks if all tabs are visible', async ({ page }) => {
test('checks if all tabs are visible', async ({ page, browserName }) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-owner');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -230,9 +228,13 @@ test.describe('Mail domain', () => {
test('checks all the elements are visible when domain exist but contains no mailboxes', async ({
page,
browserName,
}) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-owner');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -248,6 +250,7 @@ test.describe('Mail domain', () => {
test('checks all the elements are visible when domain exists and contains 2 pages of mailboxes', async ({
page,
browserName,
}) => {
const mailboxesFixtures = {
domainFr: {
@@ -324,6 +327,9 @@ test.describe('Mail domain', () => {
await interceptApiCalls();
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-owner');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -360,9 +366,15 @@ test.describe('Mail domain', () => {
},
];
test('checks expected elements are visible', async ({ page }) => {
test('checks expected elements are visible', async ({
page,
browserName,
}) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-owner');
await clickOnMailDomainsNavButton(page);
await expect(page).toHaveURL(/mail-domains\//);
@@ -404,9 +416,15 @@ test.describe('Mail domain', () => {
},
];
test('checks expected elements are visible', async ({ page }) => {
test('checks expected elements are visible', async ({
page,
browserName,
}) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-owner');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -447,9 +465,15 @@ test.describe('Mail domain', () => {
},
];
test('checks expected elements are visible', async ({ page }) => {
test('checks expected elements are visible', async ({
page,
browserName,
}) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-owner');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -476,11 +500,6 @@ test.describe('Mail domain', () => {
});
test.describe('user is member', () => {
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-member');
});
test.describe('mail domain is enabled', () => {
const mailDomainsFixtures: MailDomain[] = [
{
@@ -551,9 +570,13 @@ test.describe('Mail domain', () => {
test('checks all the elements are visible when domain exist but contains no mailboxes', async ({
page,
browserName,
}) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-member');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -569,6 +592,7 @@ test.describe('Mail domain', () => {
test('checks all the elements are visible when domain exists and contains 2 pages of mailboxes', async ({
page,
browserName,
}) => {
const mailboxesFixtures = {
domainFr: {
@@ -645,6 +669,9 @@ test.describe('Mail domain', () => {
await interceptApiCalls();
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-member');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -681,9 +708,15 @@ test.describe('Mail domain', () => {
},
];
test('checks expected elements are visible', async ({ page }) => {
test('checks expected elements are visible', async ({
page,
browserName,
}) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-member');
await clickOnMailDomainsNavButton(page);
await expect(page).toHaveURL(/mail-domains\//);
@@ -725,9 +758,15 @@ test.describe('Mail domain', () => {
},
];
test('checks expected elements are visible', async ({ page }) => {
test('checks expected elements are visible', async ({
page,
browserName,
}) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-member');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -768,9 +807,15 @@ test.describe('Mail domain', () => {
},
];
test('checks expected elements are visible', async ({ page }) => {
test('checks expected elements are visible', async ({
page,
browserName,
}) => {
await interceptCommonApiCalls(page, mailDomainsFixtures);
await page.goto('/');
await keyCloakSignIn(page, browserName, 'mail-member');
await clickOnMailDomainsNavButton(page);
await assertMailDomainUpperElementsAreVisible(page);
@@ -73,16 +73,14 @@ const mailDomainsFixtures: MailDomain[] = [
test.describe('Mail domains', () => {
test.describe('checks all the elements are visible', () => {
test.beforeEach(async ({ page, browserName }) => {
test('checks the sort button', async ({ page, browserName }) => {
await page.goto('/');
// The user is a team administrator, so they land on the team page
// This allows to prevent the redirection to the mail domains page
// to make the '/api/v1.0/mail-domains/?page=1&ordering=created_at'
// query at login, which will be cached and not called afterward in tests.
await keyCloakSignIn(page, browserName, 'team-administrator-mail-member');
});
test('checks the sort button', async ({ page }) => {
await page
.locator('menu')
.first()
@@ -128,7 +126,7 @@ test.describe('Mail domains', () => {
expect(responseSortDesc.ok()).toBeTruthy();
});
test('when no mail domain exists', async ({ page }) => {
test('when no mail domain exists', async ({ page, browserName }) => {
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
await route.fulfill({
json: {
@@ -140,6 +138,13 @@ test.describe('Mail domains', () => {
});
});
await page.goto('/');
// The user is a team administrator, so they land on the team page
// This allows to prevent the redirection to the mail domains page
// to make the '/api/v1.0/mail-domains/?page=1&ordering=created_at'
// query at login, which will be cached and not called afterward in tests.
await keyCloakSignIn(page, browserName, 'team-administrator-mail-member');
await page
.locator('menu')
.first()
@@ -152,7 +157,7 @@ test.describe('Mail domains', () => {
await expect(page.getByText('No domains exist.')).toBeVisible();
});
test('when 4 mail domains exist', async ({ page }) => {
test('when 4 mail domains exist', async ({ page, browserName }) => {
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
await route.fulfill({
json: {
@@ -164,6 +169,13 @@ test.describe('Mail domains', () => {
});
});
await page.goto('/');
// The user is a team administrator, so they land on the team page
// This allows to prevent the redirection to the mail domains page
// to make the '/api/v1.0/mail-domains/?page=1&ordering=created_at'
// query at login, which will be cached and not called afterward in tests.
await keyCloakSignIn(page, browserName, 'team-administrator-mail-member');
await page
.locator('menu')
.first()
+1 -1
View File
@@ -9,7 +9,7 @@
"test:ui": "yarn test --ui"
},
"devDependencies": {
"@playwright/test": "1.49.1",
"@playwright/test": "1.50.1",
"@types/node": "*",
"eslint-config-people": "*",
"typescript": "*"
+1 -1
View File
@@ -9,7 +9,7 @@ const baseURL = `http://localhost:${PORT}`;
*/
export default defineConfig({
// Timeout per test
timeout: 30 * 1000,
timeout: 10 * 1000,
testDir: './__tests__',
outputDir: './test-results',
+1 -1
View File
@@ -24,7 +24,7 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@types/node": "22.10.7",
"@types/node": "22.13.0",
"@types/react-dom": "19.0.3",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -6,17 +6,17 @@
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "15.1.5",
"@tanstack/eslint-plugin-query": "5.64.2",
"@typescript-eslint/eslint-plugin": "8.20.0",
"@typescript-eslint/parser": "8.20.0",
"@next/eslint-plugin-next": "15.1.6",
"@tanstack/eslint-plugin-query": "5.66.0",
"@typescript-eslint/eslint-plugin": "8.22.0",
"@typescript-eslint/parser": "8.22.0",
"eslint": "8.57.0",
"eslint-config-next": "15.1.5",
"eslint-config-next": "15.1.6",
"eslint-config-prettier": "10.0.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.11.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.1.0",
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-prettier": "5.2.3",
"eslint-plugin-react": "7.37.4",
"eslint-plugin-testing-library": "7.1.1"
+197 -185
View File
@@ -1834,57 +1834,57 @@
dependencies:
buffer "^6.0.3"
"@next/env@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.5.tgz#2a87f0be705d40b7bd1bf8c66581edc32dcd2b1f"
integrity sha512-jg8ygVq99W3/XXb9Y6UQsritwhjc+qeiO7QrGZRYOfviyr/HcdnhdBQu4gbp2rBIh2ZyBYTBMWbPw3JSCb0GHw==
"@next/env@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.6.tgz#2fa863d8c568a56b1c8328a86e621b8bdd4f2a20"
integrity sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==
"@next/eslint-plugin-next@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.5.tgz#00c2c4313760049e5fde11ba7a3bd7a8a3cf97c1"
integrity sha512-3cCrXBybsqe94UxD6DBQCYCCiP9YohBMgZ5IzzPYHmPzj8oqNlhBii5b6o1HDDaRHdz2pVnSsAROCtrczy8O0g==
"@next/eslint-plugin-next@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.6.tgz#1d6d0fd21f58a650821bafeb3a205b25276ce5e3"
integrity sha512-+slMxhTgILUntZDGNgsKEYHUvpn72WP1YTlkmEhS51vnVd7S9jEEy0n9YAMcI21vUG4akTw9voWH02lrClt/yw==
dependencies:
fast-glob "3.3.1"
"@next/swc-darwin-arm64@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.5.tgz#2b59120c1576b718ed4695508c2a9d4a3248ead7"
integrity sha512-5ttHGE75Nw9/l5S8zR2xEwR8OHEqcpPym3idIMAZ2yo+Edk0W/Vf46jGqPOZDk+m/SJ+vYZDSuztzhVha8rcdA==
"@next/swc-darwin-arm64@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz#92f99badab6cb41f4c5c11a3feffa574bd6a9276"
integrity sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==
"@next/swc-darwin-x64@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.5.tgz#362fa82504ee496af0e7364d8383585f504a03de"
integrity sha512-8YnZn7vDURUUTInfOcU5l0UWplZGBqUlzvqKKUFceM11SzfNEz7E28E1Arn4/FsOf90b1Nopboy7i7ufc4jXag==
"@next/swc-darwin-x64@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz#f56f4f8d5f6cb5d3915912ac95590d387f897da5"
integrity sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==
"@next/swc-linux-arm64-gnu@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.5.tgz#34bd054f222f45d7b3f5887cc2be3fe87b69e6a5"
integrity sha512-rDJC4ctlYbK27tCyFUhgIv8o7miHNlpCjb2XXfTLQszwAUOSbcMN9q2y3urSrrRCyGVOd9ZR9a4S45dRh6JF3A==
"@next/swc-linux-arm64-gnu@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz#0aaffae519c93d1006419d7b98c34ebfd80ecacd"
integrity sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==
"@next/swc-linux-arm64-musl@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.5.tgz#125356cf3e0a9c90e32383e462d14790a6a40763"
integrity sha512-FG5RApf4Gu+J+pHUQxXPM81oORZrKBYKUaBTylEIQ6Lz17hKVDsLbSXInfXM0giclvXbyiLXjTv42sQMATmZ0A==
"@next/swc-linux-arm64-musl@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz#e7398d3d31ca60033f708a718cd6c31edcee2e9a"
integrity sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==
"@next/swc-linux-x64-gnu@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.5.tgz#1dc8bf2374b5f3384370ef97e4b3bbead73b250c"
integrity sha512-NX2Ar3BCquAOYpnoYNcKz14eH03XuF7SmSlPzTSSU4PJe7+gelAjxo3Y7F2m8+hLT8ZkkqElawBp7SWBdzwqQw==
"@next/swc-linux-x64-gnu@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz#d76c72508f4d79d6016cab0c52640b93e590cffb"
integrity sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==
"@next/swc-linux-x64-musl@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.5.tgz#45dc68da9e1f22de4ef37d397aeb9cf40746871d"
integrity sha512-EQgqMiNu3mrV5eQHOIgeuh6GB5UU57tu17iFnLfBEhYfiOfyK+vleYKh2dkRVkV6ayx3eSqbIYgE7J7na4hhcA==
"@next/swc-linux-x64-musl@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz#0b8ba80a53e65bf8970ed11ea923001e2512c7cb"
integrity sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==
"@next/swc-win32-arm64-msvc@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.5.tgz#ce51388f07b9f0f0e1d4f22dcbba75bcca1952fa"
integrity sha512-HPULzqR/VqryQZbZME8HJE3jNFmTGcp+uRMHabFbQl63TtDPm+oCXAz3q8XyGv2AoihwNApVlur9Up7rXWRcjg==
"@next/swc-win32-arm64-msvc@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz#81b5dbbfdada2c05deef688e799af4a24097b65f"
integrity sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==
"@next/swc-win32-x64-msvc@15.1.5":
version "15.1.5"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.5.tgz#43135b3d9705ae8e6a6b90c8bdf4dc02b9ffc90c"
integrity sha512-n74fUb/Ka1dZSVYfjwQ+nSJ+ifUff7jGurFcTuJNKZmI62FFOxQXUYit/uZXPTj2cirm1rvGWHG2GhbSol5Ikw==
"@next/swc-win32-x64-msvc@15.1.6":
version "15.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz#131993c45ffd124fb4b15258e2f3f9669c143e3c"
integrity sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -2043,12 +2043,12 @@
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==
"@playwright/test@1.49.1":
version "1.49.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.49.1.tgz#55fa360658b3187bfb6371e2f8a64f50ef80c827"
integrity sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==
"@playwright/test@1.50.1":
version "1.50.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.50.1.tgz#027d00ca77ec79688764eb765cfe9a688807bf0b"
integrity sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==
dependencies:
playwright "1.49.1"
playwright "1.50.1"
"@react-aria/breadcrumbs@^3.5.19":
version "3.5.19"
@@ -3347,36 +3347,36 @@
dependencies:
tslib "^2.4.0"
"@tanstack/eslint-plugin-query@5.64.2":
version "5.64.2"
resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.64.2.tgz#8ee82dd0768703371850dcc1723691559f92841b"
integrity sha512-Xq7jRYvNtGMHjQEGUZLHgEMNB59hgTlqdmKor6cdJ6CMZ/nwmBGpnlr/dcHden7W7BPCdBVN4PWMZBICWvCNQQ==
"@tanstack/eslint-plugin-query@5.66.0":
version "5.66.0"
resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.66.0.tgz#c4537d62ca8bb94fbd7f0b1323053b03a79b39b0"
integrity sha512-CzZhBxicLDuuSJbkZ4nPcuBqWnhLu72Zt9p/7qLQ93BepVnZJV6ZDlBLBuN5eg7YRACwECPLsntnwo1zuhgseQ==
dependencies:
"@typescript-eslint/utils" "^8.18.1"
"@tanstack/query-core@5.64.2":
version "5.64.2"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.64.2.tgz#be06e7c7966d14ea3e7c82bea1086b463f2f6809"
integrity sha512-hdO8SZpWXoADNTWXV9We8CwTkXU88OVWRBcsiFrk7xJQnhm6WRlweDzMD+uH+GnuieTBVSML6xFa17C2cNV8+g==
"@tanstack/query-core@5.66.0":
version "5.66.0"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.66.0.tgz#163f670b3b4e3b3cdbff6698ad44b2edfcaed185"
integrity sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==
"@tanstack/query-devtools@5.64.2":
version "5.64.2"
resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.64.2.tgz#3d8f8abb17815a7302482b67713cb933c79ed6ba"
integrity sha512-3DautR5UpVZdk/qNIhioZVF7g8fdQZ1U98sBEEk4Tzz3tihSBNMPgwlP40nzgbPEDBIrn/j/oyyvNBVSo083Vw==
"@tanstack/query-devtools@5.65.0":
version "5.65.0"
resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz#37da5e911543b4f6d98b9a04369eab0de6044ba1"
integrity sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==
"@tanstack/react-query-devtools@5.64.2":
version "5.64.2"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.64.2.tgz#ece61dfad8032305aefd3f0eb044ccd8304ffa1b"
integrity sha512-+ZjJVnPzc8BUV/Eklu2k9T/IAyAyvwoCHqOaOrk2sbU33LFhM52BpX4eyENXn0bx5LwV3DJZgEQlIzucoemfGQ==
"@tanstack/react-query-devtools@5.66.0":
version "5.66.0"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.66.0.tgz#d54ba345cecf259181565699af1f80e0b284e714"
integrity sha512-uB57wA2YZaQ2fPcFW0E9O1zAGDGSbRKRx84uMk/86VyU9jWVxvJ3Uzp+zNm+nZJYsuekCIo2opTdgNuvM3cKgA==
dependencies:
"@tanstack/query-devtools" "5.64.2"
"@tanstack/query-devtools" "5.65.0"
"@tanstack/react-query@5.64.2":
version "5.64.2"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.64.2.tgz#199c8a5a8ff92a8565f8cdd378747398347512a2"
integrity sha512-3pakNscZNm8KJkxmovvtZ4RaXLyiYYobwleTMvpIGUoKRa8j8VlrQKNl5W8VUEfVfZKkikvXVddLuWMbcSCA1Q==
"@tanstack/react-query@5.66.0":
version "5.66.0"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.66.0.tgz#9f7aa1b3e844ea6a0ad2ee61fccaed76e614b865"
integrity sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw==
dependencies:
"@tanstack/query-core" "5.64.2"
"@tanstack/query-core" "5.66.0"
"@tanstack/react-table@8.20.6":
version "8.20.6"
@@ -3424,10 +3424,10 @@
dependencies:
"@babel/runtime" "^7.12.5"
"@testing-library/user-event@14.6.0":
version "14.6.0"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.0.tgz#6748ec1ac6df9291e92b6abc0f3530b3842bf34d"
integrity sha512-+jsfK7kVJbqnCYtLTln8Ja/NmVrZRwBJHmHR9IxIVccMWSOZ6Oy0FkDJNeyVu4QSpMNmRfy10Xb76ObRDlWWBQ==
"@testing-library/user-event@14.6.1":
version "14.6.1"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149"
integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==
"@tootallnate/once@2":
version "2.0.0"
@@ -3545,10 +3545,10 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/lodash@4.17.14":
version "4.17.14"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.14.tgz#bafc053533f4cdc5fcc9635af46a963c1f3deaff"
integrity sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==
"@types/lodash@4.17.15":
version "4.17.15"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.15.tgz#12d4af0ed17cc7600ce1f9980cec48fc17ad1e89"
integrity sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==
"@types/lodash@^4.14.168":
version "4.17.13"
@@ -3565,10 +3565,10 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
"@types/node@*", "@types/node@22.10.7":
version "22.10.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.7.tgz#14a1ca33fd0ebdd9d63593ed8d3fbc882a6d28d7"
integrity sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==
"@types/node@*", "@types/node@22.13.0":
version "22.13.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.0.tgz#d376dd9a0ee2f9382d86c2d5d7beb4d198b4ea8c"
integrity sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==
dependencies:
undici-types "~6.20.0"
@@ -3609,10 +3609,10 @@
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/react@19.0.7":
version "19.0.7"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.7.tgz#c451968b999d1cb2d9207dc5ff56496164cf511d"
integrity sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==
"@types/react@19.0.8":
version "19.0.8"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.8.tgz#7098e6159f2a61e4f4cef2c1223c044a9bec590e"
integrity sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==
dependencies:
csstype "^3.0.2"
@@ -3648,16 +3648,16 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz#b47a398e0e551cb008c60190b804394e6852c863"
integrity sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==
"@typescript-eslint/eslint-plugin@8.22.0":
version "8.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.22.0.tgz#63a1b0d24d85a971949f8d71d693019f58d2e861"
integrity sha512-4Uta6REnz/xEJMvwf72wdUnC3rr4jAQf5jnTkeRQ9b6soxLxhDEbS/pfMPoJLDfFPNVRdryqWUIV/2GZzDJFZw==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.20.0"
"@typescript-eslint/type-utils" "8.20.0"
"@typescript-eslint/utils" "8.20.0"
"@typescript-eslint/visitor-keys" "8.20.0"
"@typescript-eslint/scope-manager" "8.22.0"
"@typescript-eslint/type-utils" "8.22.0"
"@typescript-eslint/utils" "8.22.0"
"@typescript-eslint/visitor-keys" "8.22.0"
graphemer "^1.4.0"
ignore "^5.3.1"
natural-compare "^1.4.0"
@@ -3678,15 +3678,15 @@
natural-compare "^1.4.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/parser@8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.20.0.tgz#5caf2230a37094dc0e671cf836b96dd39b587ced"
integrity sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==
"@typescript-eslint/parser@8.22.0":
version "8.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.22.0.tgz#f21c5db24271f182ebbb4ba8c7ad3eb76e5f5f3a"
integrity sha512-MqtmbdNEdoNxTPzpWiWnqNac54h8JDAmkWtJExBVVnSrSmi9z+sZUt0LfKqk9rjqmKOIeRhO4fHHJ1nQIjduIQ==
dependencies:
"@typescript-eslint/scope-manager" "8.20.0"
"@typescript-eslint/types" "8.20.0"
"@typescript-eslint/typescript-estree" "8.20.0"
"@typescript-eslint/visitor-keys" "8.20.0"
"@typescript-eslint/scope-manager" "8.22.0"
"@typescript-eslint/types" "8.22.0"
"@typescript-eslint/typescript-estree" "8.22.0"
"@typescript-eslint/visitor-keys" "8.22.0"
debug "^4.3.4"
"@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
@@ -3724,13 +3724,13 @@
"@typescript-eslint/types" "8.19.1"
"@typescript-eslint/visitor-keys" "8.19.1"
"@typescript-eslint/scope-manager@8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz#aaf4198b509fb87a6527c02cfbfaf8901179e75c"
integrity sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==
"@typescript-eslint/scope-manager@8.22.0":
version "8.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.22.0.tgz#e85836ddeb8eae715f870628bcc32fe96aaf4d0e"
integrity sha512-/lwVV0UYgkj7wPSw0o8URy6YI64QmcOdwHuGuxWIYznO6d45ER0wXUbksr9pYdViAofpUCNJx/tAzNukgvaaiQ==
dependencies:
"@typescript-eslint/types" "8.20.0"
"@typescript-eslint/visitor-keys" "8.20.0"
"@typescript-eslint/types" "8.22.0"
"@typescript-eslint/visitor-keys" "8.22.0"
"@typescript-eslint/type-utils@8.13.0":
version "8.13.0"
@@ -3742,13 +3742,13 @@
debug "^4.3.4"
ts-api-utils "^1.3.0"
"@typescript-eslint/type-utils@8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz#958171d86b213a3f32b5b16b91db267968a4ef19"
integrity sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==
"@typescript-eslint/type-utils@8.22.0":
version "8.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.22.0.tgz#cd9f23c23f021357ef0baa3490d4d96edcc97509"
integrity sha512-NzE3aB62fDEaGjaAYZE4LH7I1MUwHooQ98Byq0G0y3kkibPJQIXVUspzlFOmOfHhiDLwKzMlWxaNv+/qcZurJA==
dependencies:
"@typescript-eslint/typescript-estree" "8.20.0"
"@typescript-eslint/utils" "8.20.0"
"@typescript-eslint/typescript-estree" "8.22.0"
"@typescript-eslint/utils" "8.22.0"
debug "^4.3.4"
ts-api-utils "^2.0.0"
@@ -3767,10 +3767,10 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.19.1.tgz#015a991281754ed986f2e549263a1188d6ed0a8c"
integrity sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==
"@typescript-eslint/types@8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.20.0.tgz#487de5314b5415dee075e95568b87a75a3e730cf"
integrity sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==
"@typescript-eslint/types@8.22.0":
version "8.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.22.0.tgz#d9dec7116479ad03aeb6c8ac9c5223c4c79cf360"
integrity sha512-0S4M4baNzp612zwpD4YOieP3VowOARgK2EkN/GBn95hpyF8E2fbMT55sRHWBq+Huaqk3b3XK+rxxlM8sPgGM6A==
"@typescript-eslint/typescript-estree@8.13.0":
version "8.13.0"
@@ -3814,13 +3814,13 @@
semver "^7.6.0"
ts-api-utils "^2.0.0"
"@typescript-eslint/typescript-estree@8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz#658cea07b7e5981f19bce5cf1662cb70ad59f26b"
integrity sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==
"@typescript-eslint/typescript-estree@8.22.0":
version "8.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.22.0.tgz#c188c3e19529d5b3145577c0bd967e2683b114df"
integrity sha512-SJX99NAS2ugGOzpyhMza/tX+zDwjvwAtQFLsBo3GQxiGcvaKlqGBkmZ+Y1IdiSi9h4Q0Lr5ey+Cp9CGWNY/F/w==
dependencies:
"@typescript-eslint/types" "8.20.0"
"@typescript-eslint/visitor-keys" "8.20.0"
"@typescript-eslint/types" "8.22.0"
"@typescript-eslint/visitor-keys" "8.22.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -3838,15 +3838,15 @@
"@typescript-eslint/types" "8.13.0"
"@typescript-eslint/typescript-estree" "8.13.0"
"@typescript-eslint/utils@8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.20.0.tgz#53127ecd314b3b08836b4498b71cdb86f4ef3aa2"
integrity sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==
"@typescript-eslint/utils@8.22.0":
version "8.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.22.0.tgz#c8cc4e52a9c711af8a741a82dc5d7242b7a8dd44"
integrity sha512-T8oc1MbF8L+Bk2msAvCUzjxVB2Z2f+vXYfcucE2wOmYs7ZUwco5Ep0fYZw8quNwOiw9K8GYVL+Kgc2pETNTLOg==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@typescript-eslint/scope-manager" "8.20.0"
"@typescript-eslint/types" "8.20.0"
"@typescript-eslint/typescript-estree" "8.20.0"
"@typescript-eslint/scope-manager" "8.22.0"
"@typescript-eslint/types" "8.22.0"
"@typescript-eslint/typescript-estree" "8.22.0"
"@typescript-eslint/utils@^8.15.0":
version "8.16.0"
@@ -3892,12 +3892,12 @@
"@typescript-eslint/types" "8.19.1"
eslint-visitor-keys "^4.2.0"
"@typescript-eslint/visitor-keys@8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz#2df6e24bc69084b81f06aaaa48d198b10d382bed"
integrity sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==
"@typescript-eslint/visitor-keys@8.22.0":
version "8.22.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.22.0.tgz#02cc005014c372033eb9171e2275b76cba722a3f"
integrity sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==
dependencies:
"@typescript-eslint/types" "8.20.0"
"@typescript-eslint/types" "8.22.0"
eslint-visitor-keys "^4.2.0"
"@ungap/structured-clone@^1.2.0":
@@ -5449,12 +5449,12 @@ escodegen@^2.0.0:
optionalDependencies:
source-map "~0.6.1"
eslint-config-next@15.1.5:
version "15.1.5"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.5.tgz#15b6cdd2469c7bb69af8d832bf6a10224d19c397"
integrity sha512-Awm7iUJY8toOR+fU8yTxZnA7/LyOGUGOd6cENCuDfJ3gucHOSmLdOSGJ4u+nlrs8p5qXemua42bZmq+uOzxl6Q==
eslint-config-next@15.1.6:
version "15.1.6"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.6.tgz#c056b7325dc70a247895c7c85515ebaae2bab35d"
integrity sha512-Wd1uy6y7nBbXUSg9QAuQ+xYEKli5CgUhLjz1QHW11jLDis5vK5XB3PemL6jEmy7HrdhaRFDz+GTZ/3FoH+EUjg==
dependencies:
"@next/eslint-plugin-next" "15.1.5"
"@next/eslint-plugin-next" "15.1.6"
"@rushstack/eslint-patch" "^1.10.3"
"@typescript-eslint/eslint-plugin" "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0"
"@typescript-eslint/parser" "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -5553,10 +5553,10 @@ eslint-plugin-jsx-a11y@6.10.2, eslint-plugin-jsx-a11y@^6.10.0:
safe-regex-test "^1.0.3"
string.prototype.includes "^2.0.1"
eslint-plugin-playwright@2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-playwright/-/eslint-plugin-playwright-2.1.0.tgz#9d84b5d839226e1f9ac926663baee29d103dcc99"
integrity sha512-wMbHOehofSB1cBdzz2CLaCYaKNLeTQ0YnOW+7AHa281TJqlpEJUBgTHbRUYOUxiXphfWwOyTPvgr6vvEmArbSA==
eslint-plugin-playwright@2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-playwright/-/eslint-plugin-playwright-2.2.0.tgz#d7eda21e670274fc0c006e11ba5cc2c8417b2a6e"
integrity sha512-qSQpAw7RcSzE3zPp8FMGkthaCWovHZ/BsXtpmnGax9vQLIovlh1bsZHEa2+j2lv9DWhnyeLM/qZmp7ffQZfQvg==
dependencies:
globals "^13.23.0"
@@ -6422,7 +6422,14 @@ i18next-parser@9.1.0:
vinyl "^3.0.0"
vinyl-fs "^4.0.0"
i18next@24.2.1, "i18next@^23.5.1 || ^24.2.0":
i18next@24.2.2:
version "24.2.2"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-24.2.2.tgz#3ba3d213302068d569142737f03f30929de696de"
integrity sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==
dependencies:
"@babel/runtime" "^7.23.2"
"i18next@^23.5.1 || ^24.2.0":
version "24.2.1"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-24.2.1.tgz#91e8f11fc9bd7042ec0bd36bed2dd0457aaa35fa"
integrity sha512-Q2wC1TjWcSikn1VAJg13UGIjc+okpFxQTxjVAymOnSA3RpttBQNMPf2ovcgoFVsV4QNxTfNZMAxorXZXsk4fBA==
@@ -6446,7 +6453,7 @@ ignore@^5.2.0, ignore@^5.3.1:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
ignore@^7.0.1:
ignore@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.3.tgz#397ef9315dfe0595671eefe8b633fec6943ab733"
integrity sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==
@@ -7741,17 +7748,22 @@ nanoid@^3.3.6, nanoid@^3.3.7:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
nanoid@^3.3.8:
version "3.3.8"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next@15.1.5:
version "15.1.5"
resolved "https://registry.yarnpkg.com/next/-/next-15.1.5.tgz#80972a606d17ead86323d3405abf3bd3e2c894c8"
integrity sha512-Cf/TEegnt01hn3Hoywh6N8fvkhbOuChO4wFje24+a86wKOubgVaWkDqxGVgoWlz2Hp9luMJ9zw3epftujdnUOg==
next@15.1.6:
version "15.1.6"
resolved "https://registry.yarnpkg.com/next/-/next-15.1.6.tgz#ce22fd0a8f36da1fc4aba86e3ec7e98eb248c555"
integrity sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==
dependencies:
"@next/env" "15.1.5"
"@next/env" "15.1.6"
"@swc/counter" "0.1.3"
"@swc/helpers" "0.5.15"
busboy "1.6.0"
@@ -7759,14 +7771,14 @@ next@15.1.5:
postcss "8.4.31"
styled-jsx "5.1.6"
optionalDependencies:
"@next/swc-darwin-arm64" "15.1.5"
"@next/swc-darwin-x64" "15.1.5"
"@next/swc-linux-arm64-gnu" "15.1.5"
"@next/swc-linux-arm64-musl" "15.1.5"
"@next/swc-linux-x64-gnu" "15.1.5"
"@next/swc-linux-x64-musl" "15.1.5"
"@next/swc-win32-arm64-msvc" "15.1.5"
"@next/swc-win32-x64-msvc" "15.1.5"
"@next/swc-darwin-arm64" "15.1.6"
"@next/swc-darwin-x64" "15.1.6"
"@next/swc-linux-arm64-gnu" "15.1.6"
"@next/swc-linux-arm64-musl" "15.1.6"
"@next/swc-linux-x64-gnu" "15.1.6"
"@next/swc-linux-x64-musl" "15.1.6"
"@next/swc-win32-arm64-msvc" "15.1.6"
"@next/swc-win32-x64-msvc" "15.1.6"
sharp "^0.33.5"
no-case@^3.0.4:
@@ -8083,17 +8095,17 @@ pkg-dir@^4.2.0:
dependencies:
find-up "^4.0.0"
playwright-core@1.49.1:
version "1.49.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.49.1.tgz#32c62f046e950f586ff9e35ed490a424f2248015"
integrity sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==
playwright-core@1.50.1:
version "1.50.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.50.1.tgz#6a0484f1f1c939168f40f0ab3828c4a1592c4504"
integrity sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==
playwright@1.49.1:
version "1.49.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.49.1.tgz#830266dbca3008022afa7b4783565db9944ded7c"
integrity sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==
playwright@1.50.1:
version "1.50.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.50.1.tgz#2f93216511d65404f676395bfb97b41aa052b180"
integrity sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==
dependencies:
playwright-core "1.49.1"
playwright-core "1.50.1"
optionalDependencies:
fsevents "2.3.2"
@@ -8143,12 +8155,12 @@ postcss@8.4.38:
picocolors "^1.0.0"
source-map-js "^1.2.0"
postcss@^8.4.49:
version "8.4.49"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
postcss@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.1.tgz#e2272a1f8a807fafa413218245630b5db10a3214"
integrity sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==
dependencies:
nanoid "^3.3.7"
nanoid "^3.3.8"
picocolors "^1.1.1"
source-map-js "^1.2.1"
@@ -8396,10 +8408,10 @@ react-modal@3.16.3:
react-lifecycles-compat "^3.0.0"
warning "^4.0.3"
react-select@5.9.0:
version "5.9.0"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.9.0.tgz#41325b7bfe8452a8d401b82fc4fb7d44a03e29c5"
integrity sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==
react-select@5.10.0:
version "5.10.0"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.10.0.tgz#9b5f4544cfecdfc744184b87651468ee0fb6e172"
integrity sha512-k96gw+i6N3ExgDwPIg0lUPmexl1ygPe6u5BdQFNBhkpbwroIgCNXdubtIzHfThYXYYTubwOBafoMnn7ruEP1xA==
dependencies:
"@babel/runtime" "^7.12.0"
"@emotion/cache" "^11.4.0"
@@ -9216,17 +9228,17 @@ stylelint-config-standard@37.0.0:
dependencies:
stylelint-config-recommended "^15.0.0"
stylelint-prettier@5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/stylelint-prettier/-/stylelint-prettier-5.0.2.tgz#82c0a813d8981fe094fca28126fc88cee22fa789"
integrity sha512-qJ+BN+1T2ZcKz9WIrv0x+eFGHzSUnXfXd5gL///T6XoJvr3D8/ztzz2fhtmXef7Vb8P33zBXmLTTveByr0nwBw==
stylelint-prettier@5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/stylelint-prettier/-/stylelint-prettier-5.0.3.tgz#470134ec5ed86501119be9a5cb44709b467d7c91"
integrity sha512-B6V0oa35ekRrKZlf+6+jA+i50C4GXJ7X1PPmoCqSUoXN6BrNF6NhqqhanvkLjqw2qgvrS0wjdpeC+Tn06KN3jw==
dependencies:
prettier-linter-helpers "^1.0.0"
stylelint@16.13.2:
version "16.13.2"
resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-16.13.2.tgz#a0e8aab7cedde55f185ac8af6f526e53df783294"
integrity sha512-wDlgh0mRO9RtSa3TdidqHd0nOG8MmUyVKl+dxA6C1j8aZRzpNeEgdhFmU5y4sZx4Fc6r46p0fI7p1vR5O2DZqA==
stylelint@16.14.1:
version "16.14.1"
resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-16.14.1.tgz#32d3df8c5ce7ba0275ce6a4a966b6099d7a828e3"
integrity sha512-oqCL7AC3786oTax35T/nuLL8p2C3k/8rHKAooezrPGRvUX0wX+qqs5kMWh5YYT4PHQgVDobHT4tw55WgpYG6Sw==
dependencies:
"@csstools/css-parser-algorithms" "^3.0.4"
"@csstools/css-tokenizer" "^3.0.3"
@@ -9246,7 +9258,7 @@ stylelint@16.13.2:
globby "^11.1.0"
globjoin "^0.1.4"
html-tags "^3.3.1"
ignore "^7.0.1"
ignore "^7.0.3"
imurmurhash "^0.1.4"
is-plain-object "^5.0.0"
known-css-properties "^0.35.0"
@@ -9255,7 +9267,7 @@ stylelint@16.13.2:
micromatch "^4.0.8"
normalize-path "^3.0.0"
picocolors "^1.1.1"
postcss "^8.4.49"
postcss "^8.5.1"
postcss-resolve-nested-selector "^0.1.6"
postcss-safe-parser "^7.0.1"
postcss-selector-parser "^7.0.0"
@@ -102,3 +102,11 @@ ingressAdmin:
enabled: true
host: desk.127.0.0.1.nip.io
mailcatcher:
image:
repository: sj26/mailcatcher
tag: latest
pullPolicy: Always
ingress:
enabled: true
host: mailcatcher.127.0.0.1.nip.io
@@ -107,3 +107,12 @@ ingress:
ingressAdmin:
enabled: true
host: desk.127.0.0.1.nip.io
mailcatcher:
image:
repository: sj26/mailcatcher
tag: latest
pullPolicy: Always
ingress:
enabled: true
host: mailcatcher.127.0.0.1.nip.io
+7
View File
@@ -100,3 +100,10 @@ releases:
values:
- env.d/{{ .Environment.Name }}/values.desk.yaml.gotmpl
- name: mailcatcher
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: ./mailcatcher
values:
- env.d/{{ .Environment.Name }}/values.desk.yaml.gotmpl
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v2
name: mailcatcher
description: A simple SMTP server with a web interface for testing email in development
version: 1.0.0
type: application
@@ -0,0 +1,25 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mailcatcher
labels:
app: mailcatcher
spec:
replicas: 1
selector:
matchLabels:
app: mailcatcher
template:
metadata:
labels:
app: mailcatcher
spec:
containers:
- name: mailcatcher
image: "{{ .Values.mailcatcher.image.repository }}:{{ .Values.mailcatcher.image.tag }}"
imagePullPolicy: {{ .Values.mailcatcher.image.pullPolicy }}
ports:
- name: smtp
containerPort: 1025
- name: http
containerPort: 1080
@@ -0,0 +1,20 @@
{{- if .Values.mailcatcher.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mailcatcher
labels:
app: mailcatcher
spec:
rules:
- host: {{ .Values.mailcatcher.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: mailcatcher
port:
number: 1080
{{- end }}
@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: mailcatcher
labels:
app: mailcatcher
spec:
type: ClusterIP
ports:
- port: 1025
targetPort: smtp
protocol: TCP
name: smtp
- port: 1080
targetPort: http
protocol: TCP
name: http
selector:
app: mailcatcher
+16
View File
@@ -0,0 +1,16 @@
image:
repository: sj26/mailcatcher
tag: latest
pullPolicy: IfNotPresent
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
ingress:
enabled: false
host: ""
+4 -4
View File
@@ -23,10 +23,10 @@
<mj-image src="{% base64_static 'images/logo.png' %}" width="157px" align="left" alt="{%trans 'Logo' %}" />
<!-- Main Message -->
<mj-text>{% trans "We are delighted to welcome you to our community on Régie, your new companion to simplify the management of your groups efficiently, intuitively, and securely." %}</mj-text>
<mj-text>{% trans "We are delighted to welcome you to our community on La Régie, your new companion to simplify the management of your groups efficiently, intuitively, and securely." %}</mj-text>
<mj-text>{% trans "Our application is designed to help you organize, collaborate, and manage permissions." %}</mj-text>
<mj-text>
{% trans "With Régie, you will be able to:" %}
{% trans "With La Régie, you will be able to:" %}
<ul>
<li>{% trans "Create customized groups according to your specific needs."%}</li>
<li>{% trans "Invite members of your team or community in just a few clicks."%}</li>
@@ -36,9 +36,9 @@
</ul>
</mj-text>
<mj-button href="//{{site.domain}}" background-color="#000091" color="white" padding-bottom="30px">
{% trans "Visit Régie"%}
{% trans "Visit La Régie"%}
</mj-button>
<mj-text>{% trans "We are confident that Régie will help you increase efficiency and productivity while strengthening the bond among members." %}</mj-text>
<mj-text>{% trans "We are confident that La Régie will help you increase efficiency and productivity while strengthening the bond among members." %}</mj-text>
<mj-text>{% trans "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service." %}</mj-text>
<mj-text>{% trans "Once again, welcome aboard! We are eager to accompany you on this group management adventure." %}</mj-text>