Compare commits

...

41 Commits

Author SHA1 Message Date
Sabrina Demagny 70d22eecfa 🔖(minor) release version 1.12.0
Update all version files and changelog for minor release.
2025-02-18 08:51:31 +01:00
Laurent Bossavit 7379d70321 🐛(communes) add missing user creation in domain provisioning
Add user in Dimail for automatically provisioned domains.
2025-02-18 08:30:25 +01:00
Sabrina Demagny 29d0bbb692 (frontend) display button to re-run fetch domain from dimail
Add the button in the modal which describes actions required
to make the domain work
2025-02-17 20:39:03 +01:00
Sabrina Demagny cdb766b0e0 (domains) allow to run all fetch domain data from dimail
Fetch domain status and expected config from dimail.
2025-02-17 19:40:13 +01:00
Sabrina Demagny 38de864d68 (domains) add admin action to fetch domain DNS config
Create a Django admin action to allow retrieval of
the expected domain configuration from dimail.
These values shouldn't change unless external
intervention occurs. An admin command seems sufficient to handle
hypothetical changes.
2025-02-17 19:28:43 +01:00
Sabrina Demagny cad065da84 (frontend) display domain expected config for DNS
If any action is required on the domain, display the
expected domain configuration in the modal window to
inform the user of actions required to operate the domain.
2025-02-17 19:28:43 +01:00
Sabrina Demagny 3893fdf4d7 (domains) get domain expected config for DNS
Call dimail to get DNS configuration values
to make an external domain work and save it in our db.
Add values to serializer for displaying.
2025-02-17 19:28:43 +01:00
Laurent Bossavit d29b5141b1 ️(ci) save time on CI by not downloading already present browser
See https://github.com/microsoft/playwright/issues/23388
2025-02-17 10:30:06 +01:00
renovate[bot] 110fc82250 ⬆️(dependencies) update python dependencies 2025-02-17 09:03:33 +01:00
Sabrina Demagny 95f19f7c6c 🩹(domains) add missing migrations after define ordering
Add missing migration after define ordering by created_at
for Mailbox, MailDomain and MailDomainAccess by created_at.
2025-02-14 18:37:32 +01:00
Sabrina Demagny ab03cd9db9 (domains) check status after creation
Fetch domain status from dimail just after domain creation.
2025-02-14 16:47:53 +01:00
Quentin BEY a811431070 🧑‍💻(tilt) use maildev for local kube
Switch from mailcatcher to maildev for local work purpose.
2025-02-13 15:12:45 +01:00
Quentin BEY d23ac76f36 🧑‍💻(dev) use maildev for local developments
Switch from mailcatcher to maildev for local work purpose.
2025-02-13 15:12:45 +01:00
Sabrina Demagny 9377a96e87 💄(domains) remove useless bold text broken after fbb4797
Fix domain names displaying after fetch status
from dimail failure after commit fbb4797
2025-02-13 09:31:06 +01:00
Sabrina Demagny 25313d3e84 🔒️(docker) patch libssl3 and libcrypto3 to address CVE-2024-12797
Added temporary root privileges to update OpenSSL libraries. Upgrades libssl3
and libcrypto3 to 3.3.3-r0 to fix HIGH severity vulnerability. Properly
switches back to nginx user after updates. Maintains unprivileged execution
while addressing security concern affecting RFC7250 Raw Public Keys
authentication.

Security: CVE-2024-12797
2025-02-13 08:58:28 +01:00
Sabrina Demagny 9cd1b42c3d 💄(frontend) change support email example
The domain of support email suggested was
the same as the example domain name
suggested below.
This was a bit confusing because if the
domain is broken we need to contact someone
with a working email address.
2025-02-12 22:53:06 +01:00
Laurent Bossavit d08198e44d 🔊(prod) move logging config up to Base configuration class
This move makes it possible to set logging configuration on a per-deployment
basis in production.
2025-02-12 15:04:23 +01:00
Sabrina Demagny e55468862d 🧑‍💻(domains) change default ordering
Order by last created models MailDomain, Mailbox and MailDomainAccess.
2025-02-12 10:36:55 +01:00
Laurent Bossavit fbb4797f29 🚨(pytest) fix or suppress warnings during backend tests
Avoid unnecessary noise from testing processes
2025-02-11 16:52:26 +01:00
Sabrina Demagny 0f290df24a (frontend) display required actions details on Domain
On domain page, add link to display modal with needed informations
about actions to do to fix a domain.
2025-02-11 14:07:39 +01:00
Sabrina Demagny a2b2c71448 (api) add required actions to fix domain
Send all informations about required actions
to do to fix a domain and full check domain
health from dimail too.
2025-02-11 13:07:14 +01:00
Sabrina Demagny e1594493a7 🐛(plugin) fix support email
Use a real email to contact support in case
of actions required on the domain in collectivite.fr
2025-02-11 11:15:45 +01:00
renovate[bot] e903c5d4ca ⬆️(dependencies) update python dependencies 2025-02-11 10:57:20 +01:00
renovate[bot] 0f80d5f2db ⬆️(dependencies) update js dependencies 2025-02-11 10:42:56 +01:00
Laurent Bossavit 4cb695c2bf (plugin) add CommuneCreation plugin
Add unit tests and refactor name normalization and zone naming.
2025-02-11 09:53:31 +01:00
Laurent Bossavit a68f8171cb (plugin) add CommuneCreation plugin
Add E2E test to cover the API integration, access grant &c.
2025-02-11 09:53:31 +01:00
Laurent Bossavit 471f69d4ec (plugin) add CommuneCreation plugin
Extend plugin mechanism to be able to grant domain admin in Dimail
2025-02-11 09:53:31 +01:00
Laurent Bossavit dc938d3159 (plugin) add CommuneCreation plugin
Add test for zone creation call
2025-02-11 09:53:31 +01:00
Laurent Bossavit 87907d57de (plugin) add CommuneCreation plugin
Fix earlier test that was in error
2025-02-11 09:53:31 +01:00
Laurent Bossavit 57fd15100e (plugin) add CommuneCreation plugin
Add test for domain spec from Dimail
2025-02-11 09:53:31 +01:00
Laurent Bossavit 34c9dc6cd7 (plugin) add CommuneCreation plugin
Introduces some machinery for testing and executing API orchestrations.
Rolls back some changes in NameFromSiret plugin.
2025-02-11 09:53:31 +01:00
Sabrina Demagny 1be30496be 🌐(frontend) add missing translations
Upload and complete missing translations on crowdin
and download it
2025-02-10 14:46:03 +01:00
Sabrina Demagny 0ca5fa5318 (frontend) new status action required on domain
Display new status flag on mail domain.
2025-02-10 14:25:58 +01:00
Marie PUPO JEAMMET 4d3901b35d (auth) fix empty name from ProConnect
add proconnect scopes for names to be computed automatically
upon user creation
This commit fixes the way names are computed from ProConnect claims
2025-02-10 12:59:16 +01:00
Sabrina Demagny 961bceb64e (domains) store last check domain results
Store results of last dimail check on a domain.
2025-02-10 12:24:37 +01:00
Sabrina Demagny d7957547f8 (frontend) add support email field on domain creation form
Add new field to give email of support to manage actions
required on domain.
2025-02-10 11:37:07 +01:00
Sabrina Demagny 418db6194a (domains) add support email field on MailDomain
Add new field on MailDomain to allow contact support
if some actions are required to fix domain.
2025-02-10 11:37:07 +01:00
Jacques ROUSSEL c2899f66af 🔧(helm) add pdbs to deployments
In order to avoid a service interruption during a Kubernetes
(k8s)upgrade, we add a Pod Disruption Budget (PDB) to deployments.
2025-02-10 11:35:53 +01:00
Sabrina Demagny ba3f6a504f 🌐(backend) add missing translations
Re-run back-i18n-generate and back-i18n-compile
and add some missing translations
2025-02-07 22:11:37 +01:00
Marie PUPO JEAMMET 3de495a489 🚚(github) update all mentions to github repo
github repo moved from numerique-gouv to suitenumerique org
2025-02-07 14:26:27 +01:00
Sabrina Demagny e297a025c3 🔖(minor) release version 1.11.0
Update all version files and changelog for minor release.
2025-02-07 11:46:19 +01:00
93 changed files with 3275 additions and 764 deletions
+2 -2
View File
@@ -9,9 +9,9 @@ We primarily use GitHub as an issue tracker. If however you're encountering an i
---
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people).
Please make sure you have read our [main Readme](https://github.com/suitenumerique/people).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues).
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/people/issues).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
-3
View File
@@ -201,9 +201,6 @@ jobs:
run: |
make dimail-setup-db
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
+39 -21
View File
@@ -8,6 +8,21 @@ and this project adheres to
## [Unreleased]
## [1.12.0] - 2025-02-18
### Added
- ✨(domains) allow user to re-run all fetch domain data from dimail
- ✨(domains) display DNS config expected for domain with required actions
- ✨(domains) check status after creation
- ✨(domains) display required actions to do on domain
- ✨(plugin) add CommuneCreation plugin with domain provisioning #658
- ✨(frontend) display action required status on domain
- ✨(domains) store last health check details on MailDomain
- ✨(domains) add support email field on domain
## [1.11.0] - 2025-02-07
### Added
- ✨(api) add count mailboxes to MailDomain serializer
@@ -17,6 +32,7 @@ and this project adheres to
### Fixed
- ✨(auth) fix empty names from ProConnect #687
- 🚑️(teams) do not display add button when disallowed #676
- 🚑️(plugins) fix name from SIRET specific case #674
- 🐛(api) restrict mailbox sync to enabled domains
@@ -276,24 +292,26 @@ 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.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
[1.8.0]: https://github.com/numerique-gouv/people/releases/v1.8.0
[1.7.1]: https://github.com/numerique-gouv/people/releases/v1.7.1
[1.7.0]: https://github.com/numerique-gouv/people/releases/v1.7.0
[1.6.1]: https://github.com/numerique-gouv/people/releases/v1.6.1
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
[unreleased]: https://github.com/suitenumerique/people/compare/v1.12.0...main
[1.12.0]: https://github.com/suitenumerique/people/releases/v1.12.0
[1.11.0]: https://github.com/suitenumerique/people/releases/v1.11.0
[1.10.1]: https://github.com/suitenumerique/people/releases/v1.10.1
[1.10.0]: https://github.com/suitenumerique/people/releases/v1.10.0
[1.9.1]: https://github.com/suitenumerique/people/releases/v1.9.1
[1.9.0]: https://github.com/suitenumerique/people/releases/v1.9.0
[1.8.0]: https://github.com/suitenumerique/people/releases/v1.8.0
[1.7.1]: https://github.com/suitenumerique/people/releases/v1.7.1
[1.7.0]: https://github.com/suitenumerique/people/releases/v1.7.0
[1.6.1]: https://github.com/suitenumerique/people/releases/v1.6.1
[1.6.0]: https://github.com/suitenumerique/people/releases/v1.6.0
[1.5.0]: https://github.com/suitenumerique/people/releases/v1.5.0
[1.4.1]: https://github.com/suitenumerique/people/releases/v1.4.1
[1.4.0]: https://github.com/suitenumerique/people/releases/v1.4.0
[1.3.1]: https://github.com/suitenumerique/people/releases/v1.3.1
[1.3.0]: https://github.com/suitenumerique/people/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/people/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/people/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/people/releases/v1.1.0
[1.0.2]: https://github.com/suitenumerique/people/releases/v1.0.2
[1.0.1]: https://github.com/suitenumerique/people/releases/v1.0.1
[1.0.0]: https://github.com/suitenumerique/people/releases/v1.0.0
+6
View File
@@ -41,6 +41,12 @@ RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+4
View File
@@ -72,6 +72,7 @@ data/static:
create-env-files: ## Copy the dist env files to env files
create-env-files: \
env.d/development/common \
env.d/development/france \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql
@@ -228,6 +229,9 @@ resetdb: ## flush database and create a superuser "admin"
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
env.d/development/france:
cp -n env.d/development/france.dist env.d/development/france
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
+4 -3
View File
@@ -9,8 +9,8 @@ services:
redis:
image: redis:5
mailcatcher:
image: sj26/mailcatcher:latest
maildev:
image: maildev/maildev:latest
ports:
- "1081:1080"
@@ -27,6 +27,7 @@ services:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
@@ -37,7 +38,7 @@ services:
depends_on:
- dimail
- postgresql
- mailcatcher
- maildev
- redis
celery-dev:
+1 -1
View File
@@ -65,7 +65,7 @@
"lastName": "Delamairie",
"enabled": true,
"attributes": {
"siret": "21580304000017"
"siret": "21510339100011"
},
"credentials": [
{
+1 -1
View File
@@ -10,7 +10,7 @@ PYTHONPATH=/app
# People settings
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_HOST="maildev"
DJANGO_EMAIL_PORT=1025
# Backend url
+1
View File
@@ -0,0 +1 @@
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
+2 -1
View File
@@ -7,6 +7,7 @@ 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 import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
@@ -605,7 +606,7 @@ class StatView(views.APIView):
context = {
"total_users": models.User.objects.count(),
"mau": models.User.objects.filter(
last_login__gte=datetime.datetime.now() - datetime.timedelta(30)
last_login__gte=timezone.now() - datetime.timedelta(30)
).count(),
"teams": models.Team.objects.count(),
"domains": domains_models.MailDomain.objects.count(),
+2 -3
View File
@@ -95,13 +95,12 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
email = user_info.get("email")
claims = {
"sub": sub,
"email": email,
"name": full_name,
"name": self.compute_full_name(user_info),
}
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
@@ -120,7 +119,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
# Data cleaning, to be removed when user organization is null=False
# or all users have an organization.
# See https://github.com/numerique-gouv/people/issues/504
# See https://github.com/suitenumerique/people/issues/504
if not user.organization_id:
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
+1 -1
View File
@@ -240,7 +240,7 @@ class TeamWebhookFactory(factory.django.DjangoModelFactory):
model = models.TeamWebhook
team = factory.SubFactory(TeamFactory)
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
url = factory.Sequence(lambda n: f"https://nosuchdomain.xyz/Groups/{n!s}")
class InvitationFactory(factory.django.DjangoModelFactory):
+26 -1
View File
@@ -29,7 +29,10 @@ from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookStatusChoices
from core.plugins.loader import organization_plugins_run_after_create
from core.plugins.loader import (
organization_plugins_run_after_create,
organization_plugins_run_after_grant_access,
)
from core.utils.webhooks import scim_synchronizer
from core.validators import get_field_validators_from_setting
@@ -298,6 +301,22 @@ class OrganizationManager(models.Manager):
return instance
class OrganizationAccessManager(models.Manager):
"""
Custom manager for the OrganizationAccess model, to manage complexity/automation.
"""
def create(self, **kwargs):
"""
Create an organization access with the given kwargs.
This method is overridden to call the Organization plugins.
"""
instance = super().create(**kwargs)
organization_plugins_run_after_grant_access(instance)
return instance
class Organization(BaseModel):
"""
Organization model used to regroup Teams.
@@ -618,6 +637,8 @@ class OrganizationAccess(BaseModel):
default=OrganizationRoleChoices.ADMIN,
)
objects = OrganizationAccessManager()
class Meta:
db_table = "people_organization_access"
verbose_name = _("Organization/user relation")
@@ -979,3 +1000,7 @@ class Invitation(BaseModel):
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
# It's not clear yet how best to split this file.
# pylint: disable=C0302
+6
View File
@@ -11,3 +11,9 @@ class BaseOrganizationPlugin:
def run_after_create(self, organization) -> None:
"""Method called after creating an organization."""
raise NotImplementedError("Plugins must implement the run_after_create method")
def run_after_grant_access(self, organization_access) -> None:
"""Method called after creating an organization."""
raise NotImplementedError(
"Plugins must implement the run_after_grant_access method"
)
+12
View File
@@ -30,3 +30,15 @@ def organization_plugins_run_after_create(organization):
"""
for plugin_instance in get_organization_plugins():
plugin_instance.run_after_create(organization)
def organization_plugins_run_after_grant_access(organization_access):
"""
Run the after grant access method for all organization plugins.
Each plugin will be called in the order they are listed in the settings.
Each plugin is responsible to save changes if needed, this is not optimized
but this could be easily improved later if needed.
"""
for plugin_instance in get_organization_plugins():
plugin_instance.run_after_grant_access(organization_access)
Binary file not shown.
@@ -0,0 +1,610 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-13 07:41+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:63
msgid "Personal info"
msgstr ""
#: core/admin.py:65
msgid "Permissions"
msgstr ""
#: core/admin.py:77
msgid "Important dates"
msgstr ""
#: core/admin.py:116
msgid "User"
msgstr ""
#: core/admin.py:218
msgid "Run post creation plugins"
msgstr ""
#: core/admin.py:226
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
#: core/authentication/backends.py:94
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:115
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:161
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:180
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: core/enums.py:23
msgid "Failure"
msgstr ""
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr ""
#: core/enums.py:25
msgid "Success"
msgstr ""
#: core/models.py:50
msgid "Member"
msgstr ""
#: core/models.py:51 core/models.py:63 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
#: core/models.py:52 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: core/models.py:75
msgid "id"
msgstr ""
#: core/models.py:76
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:82
msgid "created at"
msgstr ""
#: core/models.py:83
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:88
msgid "updated at"
msgstr ""
#: core/models.py:89
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:128
msgid "full name"
msgstr ""
#: core/models.py:129
msgid "short name"
msgstr ""
#: core/models.py:132
msgid "notes"
msgstr ""
#: core/models.py:134
msgid "contact information"
msgstr ""
#: core/models.py:135
msgid "A JSON object containing the contact information"
msgstr ""
#: core/models.py:149
msgid "contact"
msgstr ""
#: core/models.py:150
msgid "contacts"
msgstr ""
#: core/models.py:224 core/models.py:338 core/models.py:460
#: mailbox_manager/models.py:24
msgid "name"
msgstr ""
#: core/models.py:226
msgid "audience id"
msgstr ""
#: core/models.py:231
msgid "service provider"
msgstr ""
#: core/models.py:232
msgid "service providers"
msgstr ""
#: core/models.py:346
msgid "registration ID list"
msgstr ""
#: core/models.py:353
msgid "domain list"
msgstr ""
#: core/models.py:369
msgid "organization"
msgstr ""
#: core/models.py:370
msgid "organizations"
msgstr ""
#: core/models.py:377
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
#: core/models.py:445
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:451
msgid "sub"
msgstr ""
#: core/models.py:453
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:459 core/models.py:901
msgid "email address"
msgstr ""
#: core/models.py:465
msgid "language"
msgstr ""
#: core/models.py:466
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:472
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:475
msgid "device"
msgstr ""
#: core/models.py:477
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:480
msgid "staff status"
msgstr ""
#: core/models.py:482
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:485
msgid "active"
msgstr ""
#: core/models.py:488
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:507
msgid "user"
msgstr ""
#: core/models.py:508
msgid "users"
msgstr ""
#: core/models.py:644
msgid "Organization/user relation"
msgstr ""
#: core/models.py:645
msgid "Organization/user relations"
msgstr ""
#: core/models.py:650
msgid "This user is already in this organization."
msgstr ""
#: core/models.py:727
msgid "Team"
msgstr ""
#: core/models.py:728
msgid "Teams"
msgstr ""
#: core/models.py:779
msgid "Team/user relation"
msgstr ""
#: core/models.py:780
msgid "Team/user relations"
msgstr ""
#: core/models.py:785
msgid "This user is already in this team."
msgstr ""
#: core/models.py:874
msgid "url"
msgstr ""
#: core/models.py:875
msgid "secret"
msgstr ""
#: core/models.py:884
msgid "Team webhook"
msgstr ""
#: core/models.py:885
msgid "Team webhooks"
msgstr ""
#: core/models.py:918
msgid "Team invitation"
msgstr ""
#: core/models.py:919
msgid "Team invitations"
msgstr ""
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr ""
#: core/models.py:985
msgid "Invitation to join La Régie!"
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: 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 ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
#: core/templates/mail/html/invitation.html:198
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to"
msgstr ""
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: 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 La Régie, your new "
"companion to simplify the management of your groups efficiently, "
"intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With La Régie, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create customized groups according to your specific needs."
msgstr ""
#: 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 ""
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid ""
"Plan events, meetings, or activities effortlessly with our integrated "
"calendar."
msgstr ""
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/text/invitation.txt:20
msgid "Share documents, photos, and important information securely."
msgstr ""
#: core/templates/mail/html/invitation.html:241
#: core/templates/mail/text/invitation.txt:21
msgid ""
"Facilitate exchanges and communication with our messaging and group "
"discussion tools."
msgstr ""
#: core/templates/mail/html/invitation.html:252
#: core/templates/mail/text/invitation.txt:23
msgid "Visit La Régie"
msgstr ""
#: core/templates/mail/html/invitation.html:261
#: core/templates/mail/text/invitation.txt:25
msgid ""
"We are confident that La Régie will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:266
#: core/templates/mail/text/invitation.txt:26
msgid ""
"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."
msgstr ""
#: core/templates/mail/html/invitation.html:271
#: core/templates/mail/text/invitation.txt:27
msgid ""
"Once again, welcome aboard! We are eager to accompany you on this group "
"management adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:278
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/text/invitation.txt:29
#: core/templates/mail/text/new_mailbox.txt:15
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:279
#: core/templates/mail/text/invitation.txt:31
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/text/new_mailbox.txt:3
msgid "La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:188
#: core/templates/mail/text/new_mailbox.txt:5
msgid "Welcome to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:193
#: core/templates/mail/text/new_mailbox.txt:6
msgid "La Messagerie is the email solution of La Suite."
msgstr ""
#: core/templates/mail/html/new_mailbox.html:199
#: core/templates/mail/text/new_mailbox.txt:7
msgid "Your mailbox has been created."
msgstr ""
#: core/templates/mail/html/new_mailbox.html:204
#: core/templates/mail/text/new_mailbox.txt:8
msgid "Please find below your login info: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/text/new_mailbox.txt:10
msgid "Email address: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/text/new_mailbox.txt:13
msgid "Go to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/text/new_mailbox.txt:17
msgid "La Suite Team"
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: mailbox_manager/admin.py:16
msgid "Synchronise from dimail"
msgstr ""
#: mailbox_manager/admin.py:34
#, python-brace-format
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
msgstr ""
#: mailbox_manager/admin.py:40
#, python-brace-format
msgid "Synchronisation succeed for {domain.name}. "
msgstr ""
#: mailbox_manager/admin.py:48
msgid ""
"Sync require enabled domains. Excluded domains: {', '.join(excluded_domains)}"
msgstr ""
#: mailbox_manager/admin.py:53
msgid "Check and update status from dimail"
msgstr ""
#: mailbox_manager/admin.py:69
#, python-brace-format
msgid "- {domain.name} with message: '{err}'"
msgstr ""
#: mailbox_manager/admin.py:80
msgid "Check domains done with success."
msgstr ""
#: mailbox_manager/admin.py:81
msgid "Domains updated: {', '.join(domains_updated)}"
msgstr ""
#: mailbox_manager/admin.py:83
msgid "No domain updated."
msgstr ""
#: mailbox_manager/admin.py:90
msgid "Check domain failed for:"
msgstr ""
#: mailbox_manager/admin.py:99
msgid "Domains disabled are excluded from check: {', '.join(excluded_domains)}"
msgstr ""
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr ""
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr ""
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr ""
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr ""
#: mailbox_manager/models.py:32
msgid "support email"
msgstr ""
#: mailbox_manager/models.py:36
msgid "last check details"
msgstr ""
#: mailbox_manager/models.py:37
msgid "A JSON object containing the last health check details"
msgstr ""
#: mailbox_manager/models.py:42
msgid "Mail domain"
msgstr ""
#: mailbox_manager/models.py:43
msgid "Mail domains"
msgstr ""
#: mailbox_manager/models.py:110
msgid "User/mail domain relation"
msgstr ""
#: mailbox_manager/models.py:111
msgid "User/mail domain relations"
msgstr ""
#: mailbox_manager/models.py:184
msgid "local_part"
msgstr ""
#: mailbox_manager/models.py:198
msgid "secondary email address"
msgstr ""
#: mailbox_manager/models.py:208
msgid "Mailbox"
msgstr ""
#: mailbox_manager/models.py:209
msgid "Mailboxes"
msgstr ""
#: mailbox_manager/models.py:234
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: mailbox_manager/utils/dimail.py:266
msgid "Your new mailbox information"
msgstr ""
#: people/settings.py:146
msgid "English"
msgstr ""
#: people/settings.py:147
msgid "French"
msgstr ""
Binary file not shown.
+100 -81
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-03 10:27+0000\n"
"POT-Creation-Date: 2025-02-13 08:30+0000\n"
"PO-Revision-Date: 2024-01-03 23:15\n"
"Last-Translator: \n"
"Language-Team: French\n"
@@ -49,17 +49,17 @@ msgstr ""
"Les informations de l'utilisateur ne contiennent aucune identification "
"reconnaissable"
#: core/authentication/backends.py:116
#: core/authentication/backends.py:115
msgid "User account is disabled"
msgstr "Le compte de l'utilisateur est désactivé"
#: core/authentication/backends.py:162
#: core/authentication/backends.py:161
msgid "Claims contained no recognizable user identification"
msgstr ""
"Les claims ne contiennent aucune identification reconnaissable pour "
"l'utilisateur"
#: core/authentication/backends.py:181
#: core/authentication/backends.py:180
msgid "Claims contained no recognizable organization identification"
msgstr ""
"Les claims ne contiennent aucune identification reconnaissable pour "
@@ -77,110 +77,110 @@ msgstr "En attente"
msgid "Success"
msgstr "Réussi"
#: core/models.py:47
#: core/models.py:50
msgid "Member"
msgstr "Membre"
#: core/models.py:48 core/models.py:60 mailbox_manager/enums.py:14
#: core/models.py:51 core/models.py:63 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:49 mailbox_manager/enums.py:15
#: core/models.py:52 mailbox_manager/enums.py:15
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:72
#: core/models.py:75
msgid "id"
msgstr "identifiant"
#: core/models.py:73
#: core/models.py:76
msgid "primary key for the record as UUID"
msgstr "Clé primaire pour l'enregistrement en tant que UUID"
#: core/models.py:79
#: core/models.py:82
msgid "created at"
msgstr "Créé le"
#: core/models.py:80
#: core/models.py:83
msgid "date and time at which a record was created"
msgstr "Date et heure de création de l'enregistrement"
#: core/models.py:85
#: core/models.py:88
msgid "updated at"
msgstr "mis à jour le"
#: core/models.py:86
#: core/models.py:89
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
#: core/models.py:128
msgid "full name"
msgstr "nom complet"
#: core/models.py:126
#: core/models.py:129
msgid "short name"
msgstr "nom court"
#: core/models.py:129
#: core/models.py:132
msgid "notes"
msgstr "notes"
#: core/models.py:131
#: core/models.py:134
msgid "contact information"
msgstr "informations de contact"
#: core/models.py:132
#: core/models.py:135
msgid "A JSON object containing the contact information"
msgstr "Un objet JSON contenant les informations de contact"
#: core/models.py:146
#: core/models.py:149
msgid "contact"
msgstr "contact"
#: core/models.py:147
#: core/models.py:150
msgid "contacts"
msgstr "contacts"
#: core/models.py:221 core/models.py:319 core/models.py:441
#: core/models.py:224 core/models.py:338 core/models.py:460
#: mailbox_manager/models.py:24
msgid "name"
msgstr "nom"
#: core/models.py:223
#: core/models.py:226
msgid "audience id"
msgstr ""
#: core/models.py:228
#: core/models.py:231
msgid "service provider"
msgstr "fournisseur de service"
#: core/models.py:229
#: core/models.py:232
msgid "service providers"
msgstr "fournisseurs de service"
#: core/models.py:327
#: core/models.py:346
msgid "registration ID list"
msgstr "liste d'identifiants d'inscription"
#: core/models.py:334
#: core/models.py:353
msgid "domain list"
msgstr "liste de domaines"
#: core/models.py:350
#: core/models.py:369
msgid "organization"
msgstr "organisation"
#: core/models.py:351
#: core/models.py:370
msgid "organizations"
msgstr "organisations"
#: core/models.py:358
#: core/models.py:377
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:426
#: core/models.py:445
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -188,11 +188,11 @@ msgstr ""
"Entrez une sub valide. Cette valeur peut contenir uniquement des lettres, "
"des chiffres et des caractères @/./+/-/_"
#: core/models.py:432
#: core/models.py:451
msgid "sub"
msgstr "sub"
#: core/models.py:434
#: core/models.py:453
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -200,43 +200,43 @@ msgstr ""
"Requis. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ "
"uniquement."
#: core/models.py:440 core/models.py:880
#: core/models.py:459 core/models.py:901
msgid "email address"
msgstr "adresse email"
#: core/models.py:446
#: core/models.py:465
msgid "language"
msgstr "langue"
#: core/models.py:447
#: core/models.py:466
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:453
#: core/models.py:472
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:456
#: core/models.py:475
msgid "device"
msgstr "appareil"
#: core/models.py:458
#: core/models.py:477
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:461
#: core/models.py:480
msgid "staff status"
msgstr ""
#: core/models.py:463
#: core/models.py:482
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à cette interface d'administration."
#: core/models.py:466
#: core/models.py:485
msgid "active"
msgstr ""
#: core/models.py:469
#: core/models.py:488
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -244,75 +244,75 @@ 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
#: core/models.py:507
msgid "user"
msgstr "utilisateur"
#: core/models.py:489
#: core/models.py:508
msgid "users"
msgstr "utilisateurs"
#: core/models.py:623
#: core/models.py:644
msgid "Organization/user relation"
msgstr "Relation entre une organisation et un utilisateur"
#: core/models.py:624
#: core/models.py:645
msgid "Organization/user relations"
msgstr "Relations entre une organisation et un utilisateur"
#: core/models.py:629
#: core/models.py:650
msgid "This user is already in this organization."
msgstr "Cet utilisateur est déjà dans cette organisation."
#: core/models.py:706
#: core/models.py:727
msgid "Team"
msgstr "Equipe"
#: core/models.py:707
#: core/models.py:728
msgid "Teams"
msgstr "Equipes"
#: core/models.py:758
#: core/models.py:779
msgid "Team/user relation"
msgstr "Relation entre une équipe et un utilisateur"
#: core/models.py:759
#: core/models.py:780
msgid "Team/user relations"
msgstr "Relations entre une équipe et un utilisateur"
#: core/models.py:764
#: core/models.py:785
msgid "This user is already in this team."
msgstr "Cet utilisateur est déjà dans cette équipe."
#: core/models.py:853
#: core/models.py:874
msgid "url"
msgstr "url"
#: core/models.py:854
#: core/models.py:875
msgid "secret"
msgstr "secret"
#: core/models.py:863
#: core/models.py:884
msgid "Team webhook"
msgstr "Webhook d'équipe"
#: core/models.py:864
#: core/models.py:885
msgid "Team webhooks"
msgstr "Webhooks d'équipe"
#: core/models.py:897
#: core/models.py:918
msgid "Team invitation"
msgstr "Invitation d'équipe"
#: core/models.py:898
#: core/models.py:919
msgid "Team invitations"
msgstr "Invitations d'équipe"
#: core/models.py:923
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: core/models.py:965 core/models.py:971
#: core/models.py:985
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie!"
@@ -519,46 +519,53 @@ msgstr "L'équipe de La Suite"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr "Cette mail a été envoyée à %(email)s par %(name)s [%(href)s]"
#: mailbox_manager/admin.py:13
#: mailbox_manager/admin.py:16
msgid "Synchronise from dimail"
msgstr "Synchroniser à partir de dimail"
#: mailbox_manager/admin.py:24
#: mailbox_manager/admin.py:34
#, python-brace-format
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
msgstr "Synchronisation échouée pour {domain.name} avec le message: [{err}]"
#: mailbox_manager/admin.py:30
#: mailbox_manager/admin.py:40
#, python-brace-format
msgid "Synchronisation succeed for {domain.name}. "
msgstr "Synchronisation réussie pour {domain.name}. "
#: mailbox_manager/admin.py:36
#: mailbox_manager/admin.py:48
msgid ""
"Sync require enabled domains. Excluded domains: {', '.join(excluded_domains)}"
msgstr ""
"Le synchro nécessite des domaines activés. Les domaines exclus sont : {', '."
"join(excluded_domains)}"
#: mailbox_manager/admin.py:53
msgid "Check and update status from dimail"
msgstr "Vérification et mise à jour de l'état à partir de dimail"
#: mailbox_manager/admin.py:52
#: mailbox_manager/admin.py:69
#, python-brace-format
msgid "- <b>{domain.name}</b> with message: '{err}'"
msgstr "- <b>{domain.name}</b> avec le message: '{err}'"
msgid "- {domain.name} with message: '{err}'"
msgstr "- {domain.name} avec le message: '{err}'"
#: mailbox_manager/admin.py:63
#: mailbox_manager/admin.py:80
msgid "Check domains done with success."
msgstr "Vérification des domains réalisée avec success."
#: mailbox_manager/admin.py:64
#: mailbox_manager/admin.py:81
msgid "Domains updated: {', '.join(domains_updated)}"
msgstr "Domaines mis à jour : {', '.join(domains_updated)}"
#: mailbox_manager/admin.py:66
#: mailbox_manager/admin.py:83
msgid "No domain updated."
msgstr "Aucun domain n'a été mis à jour."
#: mailbox_manager/admin.py:70
#: mailbox_manager/admin.py:90
msgid "Check domain failed for:"
msgstr "La vérification de domain a échoué pour :"
#: mailbox_manager/admin.py:76
#: mailbox_manager/admin.py:99
msgid "Domains disabled are excluded from check: {', '.join(excluded_domains)}"
msgstr ""
"Les domains désactivés sont exclus de la vérification : {', '."
@@ -584,39 +591,51 @@ msgstr "Désactivé"
msgid "Action required"
msgstr "Action requise"
#: mailbox_manager/models.py:35
#: mailbox_manager/models.py:32
msgid "support email"
msgstr "adresse email du support"
#: mailbox_manager/models.py:36
msgid "last check details"
msgstr "détails de la dernière vérification"
#: mailbox_manager/models.py:37
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les détails de la dernière vérification"
#: mailbox_manager/models.py:42
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: mailbox_manager/models.py:36
#: mailbox_manager/models.py:43
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: mailbox_manager/models.py:102
#: mailbox_manager/models.py:110
msgid "User/mail domain relation"
msgstr "Relation entre un utilisateur et un domaine de mail"
#: mailbox_manager/models.py:103
#: mailbox_manager/models.py:111
msgid "User/mail domain relations"
msgstr "Relations entre un utilisateur et un domaine de mail"
#: mailbox_manager/models.py:175
#: mailbox_manager/models.py:184
msgid "local_part"
msgstr "local_part"
#: mailbox_manager/models.py:189
#: mailbox_manager/models.py:198
msgid "secondary email address"
msgstr "adresse email secondaire"
#: mailbox_manager/models.py:199
#: mailbox_manager/models.py:208
msgid "Mailbox"
msgstr "Boîte mail"
#: mailbox_manager/models.py:200
#: mailbox_manager/models.py:209
msgid "Mailboxes"
msgstr "Boîtes mails"
#: mailbox_manager/models.py:224
#: mailbox_manager/models.py:234
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é."
+47 -5
View File
@@ -1,7 +1,7 @@
"""Admin classes and registrations for People's mailbox manager app."""
from django.contrib import admin, messages
from django.utils.html import format_html
from django.utils.html import format_html_join, mark_safe
from django.utils.translation import gettext_lazy as _
from requests import exceptions
@@ -9,6 +9,9 @@ from requests import exceptions
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
# Prevent Ruff complaining about mark_safe below
# ruff: noqa: S308
@admin.action(description=_("Synchronise from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
@@ -63,7 +66,7 @@ def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: d
try:
response = client.fetch_domain_status(domain)
except exceptions.HTTPError as err:
msg_error.append(_(f"""- <b>{domain.name}</b> with message: '{err}'"""))
msg_error.append(_(f"- {domain.name} with message: '{err}'"))
else:
success = True
# temporary (or not?) display content of the dimail response to debug broken state
@@ -79,10 +82,16 @@ def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: d
if domains_updated
else _("No domain updated."),
]
messages.success(request, format_html("<br> ".join(map(str, msg_success))))
messages.success(
request,
format_html_join(mark_safe("<br> "), "{}", ([str(m)] for m in msg_success)),
)
if msg_error:
msg_error.insert(0, _("Check domain failed for:"))
messages.error(request, format_html("<br> ".join(map(str, msg_error))))
messages.error(
request,
format_html_join(mark_safe("<br> "), "{}", ([str(m)] for m in msg_error)),
)
if excluded_domains:
messages.warning(
request,
@@ -92,6 +101,35 @@ def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: d
)
@admin.action(description=_("Fetch domain expected config from dimail"))
def fetch_domain_expected_config_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to fetch domain expected config from dimail."""
client = DimailAPIClient()
excluded_domains = []
for domain in queryset:
# do not check disabled domains
if domain.status == enums.MailDomainStatusChoices.DISABLED:
excluded_domains.append(domain.name)
continue
response = client.fetch_domain_expected_config(domain)
if response:
messages.success(
request,
_(f"Domain expected config fetched with success for {domain.name}."),
)
else:
messages.error(
request, _(f"Failed to fetch domain expected config for {domain.name}.")
)
if excluded_domains:
messages.warning(
request,
_(
f"Domains disabled are excluded from fetch: {', '.join(excluded_domains)}"
),
)
class UserMailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
@@ -115,7 +153,11 @@ class MailDomainAdmin(admin.ModelAdmin):
readonly_fields = ["created_at", "slug"]
list_filter = ("status",)
inlines = (UserMailDomainAccessInline,)
actions = (sync_mailboxes_from_dimail, fetch_domain_status_from_dimail)
actions = (
sync_mailboxes_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
)
@admin.register(models.Mailbox)
@@ -57,6 +57,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
abilities = serializers.SerializerMethodField(read_only=True)
count_mailboxes = serializers.SerializerMethodField(read_only=True)
action_required_details = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomain
@@ -70,6 +71,10 @@ class MailDomainSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"count_mailboxes",
"support_email",
"last_check_details",
"action_required_details",
"expected_config",
]
read_only_fields = [
"id",
@@ -79,8 +84,24 @@ class MailDomainSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"count_mailboxes",
"last_check_details",
"action_required_details",
"expected_config",
]
def get_action_required_details(self, domain) -> dict:
"""Return last check details of the domain."""
details = {}
if domain.last_check_details:
for check, value in domain.last_check_details.items():
if (
isinstance(value, dict)
and value.get("ok") is False
and value.get("internal") is False
):
details[check] = value["errors"][0].get("detail")
return details
def get_abilities(self, domain) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
@@ -99,9 +120,18 @@ class MailDomainSerializer(serializers.ModelSerializer):
# send new domain request to dimail
client = DimailAPIClient()
client.create_domain(validated_data["name"], self.context["request"].user.sub)
# no exception raised ? Then actually save domain on our database
return super().create(validated_data)
domain = super().create(validated_data)
# check domain status and update it
try:
client.fetch_domain_status(domain)
client.fetch_domain_expected_config(domain)
except HTTPError as e:
logger.exception(
"[DIMAIL] domain status fetch after creation failed %s with error %s",
domain.name,
e,
)
return domain
class MailDomainAccessSerializer(serializers.ModelSerializer):
@@ -33,7 +33,11 @@ class MailDomainViewSet(
POST /api/<version>/mail-domains/ with expected data:
- name: str
- support_email: str
Return newly created domain
POST /api/<version>/mail-domains/<domain-slug>/fetch/
Fetch domain status and expected config from dimail.
"""
permission_classes = [permissions.AccessPermission]
@@ -60,6 +64,17 @@ class MailDomainViewSet(
}
)
@action(detail=True, methods=["post"], url_path="fetch")
def fetch_from_dimail(self, request, *args, **kwargs):
"""Fetch domain status and expected config from dimail."""
domain = self.get_object()
client = DimailAPIClient()
client.fetch_domain_status(domain)
client.fetch_domain_expected_config(domain)
return Response(
serializers.MailDomainSerializer(domain, context={"request": request}).data
)
# pylint: disable=too-many-ancestors
class MailDomainAccessViewSet(
+1
View File
@@ -27,6 +27,7 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
name = factory.Faker("domain_name")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
support_email = factory.Faker("email")
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -48,7 +48,7 @@ class Command(BaseCommand):
# protected behind admin rights but dimail allows to create a first user
# when database is empty
self.create_user(
auth=(None, None),
auth=("", ""),
name=admin["username"],
password=admin["password"],
perms=[],
@@ -66,7 +66,11 @@ class Command(BaseCommand):
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name, defaults={"status": enums.MailDomainStatusChoices.ENABLED}
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
)[0]
self.create_domain(domain_name)
@@ -0,0 +1,31 @@
# Generated by Django 5.1.5 on 2025-02-02 21:49
from django.db import migrations, models
def fill_support_email(apps, schema_editor):
MailDomain = apps.get_model('mailbox_manager', 'MailDomain')
for domain in MailDomain.objects.filter(support_email__isnull=True):
domain.support_email = "support-regie@numerique.gouv.fr"
domain.save()
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0017_alter_maildomain_status'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='support_email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='support email'),
),
migrations.RunPython(fill_support_email, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='maildomain',
name='support_email',
field=models.EmailField(max_length=254, verbose_name='support email'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-07 20:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0018_maildomain_support_email'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='last_check_details',
field=models.JSONField(blank=True, help_text='A JSON object containing the last health check details', null=True, verbose_name='last check details'),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.1.6 on 2025-02-14 17:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0019_maildomain_last_check_details'),
]
operations = [
migrations.AlterModelOptions(
name='mailbox',
options={'ordering': ['-created_at'], 'verbose_name': 'Mailbox', 'verbose_name_plural': 'Mailboxes'},
),
migrations.AlterModelOptions(
name='maildomain',
options={'ordering': ['-created_at'], 'verbose_name': 'Mail domain', 'verbose_name_plural': 'Mail domains'},
),
migrations.AlterModelOptions(
name='maildomainaccess',
options={'ordering': ['-created_at'], 'verbose_name': 'User/mail domain relation', 'verbose_name_plural': 'User/mail domain relations'},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-02-14 17:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0020_alter_mailbox_options_alter_maildomain_options_and_more'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='expected_config',
field=models.JSONField(blank=True, help_text='A JSON object containing the expected config', null=True, verbose_name='expected config'),
),
]
+16
View File
@@ -29,11 +29,25 @@ class MailDomain(BaseModel):
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
support_email = models.EmailField(_("support email"), null=False, blank=False)
last_check_details = models.JSONField(
null=True,
blank=True,
verbose_name=_("last check details"),
help_text=_("A JSON object containing the last health check details"),
)
expected_config = models.JSONField(
null=True,
blank=True,
verbose_name=_("expected config"),
help_text=_("A JSON object containing the expected config"),
)
class Meta:
db_table = "people_mail_domain"
verbose_name = _("Mail domain")
verbose_name_plural = _("Mail domains")
ordering = ["-created_at"]
def __str__(self):
return self.name
@@ -102,6 +116,7 @@ class MailDomainAccess(BaseModel):
verbose_name = _("User/mail domain relation")
verbose_name_plural = _("User/mail domain relations")
unique_together = ("user", "domain")
ordering = ["-created_at"]
def __str__(self):
return f"Access of user {self.user} on domain {self.domain}."
@@ -199,6 +214,7 @@ class Mailbox(BaseModel):
verbose_name = _("Mailbox")
verbose_name_plural = _("Mailboxes")
unique_together = ("local_part", "domain")
ordering = ["-created_at"]
def __str__(self):
return f"{self.local_part!s}@{self.domain.name:s}"
@@ -2,6 +2,7 @@
Tests for MailDomains API endpoint in People's app mailbox_manager. Focus on "create" action.
"""
import json
import logging
import re
@@ -14,6 +15,11 @@ from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
from mailbox_manager.tests.fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
)
pytestmark = pytest.mark.django_db
@@ -52,6 +58,7 @@ def test_api_mail_domains__create_name_unique():
assert response.json()["name"] == ["Mail domain with this name already exists."]
@responses.activate
def test_api_mail_domains__create_authenticated():
"""
Authenticated users should be able to create mail domains
@@ -64,44 +71,64 @@ def test_api_mail_domains__create_authenticated():
domain_name = "test.domain.fr"
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{"name": domain_name, "context": "null", "features": ["webmail"]},
format="json",
)
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_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/{domain_name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"context": "null",
"features": ["webmail"],
"support_email": f"support@{domain_name}",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
domain = models.MailDomain.objects.get()
@@ -110,25 +137,43 @@ def test_api_mail_domains__create_authenticated():
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": enums.MailDomainStatusChoices.PENDING,
"status": enums.MailDomainStatusChoices.ACTION_REQUIRED,
"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,
"support_email": domain.support_email,
"last_check_details": body_content_domain1,
"action_required_details": {
"cname_imap": "Il faut un CNAME 'imap.example.fr' qui renvoie vers "
"'imap.ox.numerique.gouv.fr.'",
"cname_smtp": "Le CNAME pour 'smtp.example.fr' n'est pas bon, "
"il renvoie vers 'ns0.ovh.net.' et je veux 'smtp.ox.numerique.gouv.fr.'",
"cname_webmail": "Il faut un CNAME 'webmail.example.fr' qui "
"renvoie vers 'webmail.ox.numerique.gouv.fr.'",
"dkim": "Il faut un DKIM record, avec la bonne clef",
"mx": "Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr., "
"or je trouve example-fr.mail.protection.outlook.com.",
"spf": "Le SPF record ne contient pas include:_spf.ox.numerique.gouv.fr",
},
"expected_config": DOMAIN_SPEC,
}
# a new domain with status "pending" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.PENDING
# a new domain with status "action required" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain.last_check_details == body_content_domain1
assert domain.name == domain_name
assert domain.accesses.filter(role="owner", user=user).exists()
def test_api_mail_domains__create_authenticated__dimail_failure():
@responses.activate
def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
"""
Despite a dimail failure for user and/or allow creation,
an authenticated user should be able to create a mail domain
and should automatically be added as owner of the newly created domain.
"""
caplog.set_level(logging.ERROR)
user = core_factories.UserFactory()
client = APIClient()
@@ -136,45 +181,60 @@ def test_api_mail_domains__create_authenticated__dimail_failure():
domain_name = "test.domain.fr"
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{"name": domain_name, "context": "null", "features": ["webmail"]},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
dimail_error = {
"status_code": status.HTTP_401_UNAUTHORIZED,
"detail": "Not authorized",
}
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(dimail_error),
status=dimail_error["status_code"],
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"context": "null",
"features": ["webmail"],
"support_email": f"support@{domain_name}",
},
format="json",
)
domain = models.MailDomain.objects.get()
# response is as expected
@@ -187,15 +247,22 @@ def test_api_mail_domains__create_authenticated__dimail_failure():
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 0,
"support_email": domain.support_email,
"last_check_details": None,
"action_required_details": {},
"expected_config": None,
}
# a new domain with status "failed" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.FAILED
assert domain.name == domain_name
assert domain.accesses.filter(role="owner", user=user).exists()
assert caplog.records[0].levelname == "ERROR"
assert "Not authorized" in caplog.records[0].message
## SYNC TO DIMAIL
@responses.activate
def test_api_mail_domains__create_dimail_domain(caplog):
"""
Creating a domain should trigger a call to create a domain on dimail too.
@@ -207,49 +274,65 @@ def test_api_mail_domains__create_dimail_domain(caplog):
client.force_login(user)
domain_name = "test.fr"
with responses.RequestsMock() as rsps:
rsp = rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
},
format="json",
)
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_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/{domain_name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"support_email": f"support@{domain_name}",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert rsp.call_count == 1 # endpoint was called
# Logger
assert len(caplog.records) == 4 # should be 3. Last empty info still here.
@@ -267,6 +350,7 @@ def test_api_mail_domains__create_dimail_domain(caplog):
)
@responses.activate
def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"""No domain should be created when dimail returns a 409 conflict."""
user = core_factories.UserFactory()
@@ -278,27 +362,47 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"status_code": status.HTTP_409_CONFLICT,
"detail": "Domain already exists",
}
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str({"detail": dimail_error["detail"]}),
status=dimail_error["status_code"],
content_type="application/json",
)
with responses.RequestsMock() as rsps:
rsp = rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str({"detail": dimail_error["detail"]}),
status=dimail_error["status_code"],
content_type="application/json",
with pytest.raises(HTTPError):
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"support_email": f"support@{domain_name}",
},
format="json",
)
with pytest.raises(HTTPError):
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
},
format="json",
)
assert rsp.call_count == 1 # endpoint was called
assert response.status_code == dimail_error["status_code"]
assert response.json() == {"detail": dimail_error["detail"]}
assert response.status_code == dimail_error["status_code"]
assert response.json() == {"detail": dimail_error["detail"]}
# check logs
record = caplog.records[0]
@@ -0,0 +1,143 @@
"""
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "fetch" action.
"""
import json
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
from mailbox_manager.tests.fixtures import dimail as dimail_fixtures
pytestmark = pytest.mark.django_db
@responses.activate
def test_api_mail_domains__fetch_from_dimail__anonymous():
"""
Anonymous users should not be allowed to fetch a domain from dimail.
"""
client = APIClient()
domain = factories.MailDomainFactory()
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/fetch/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@responses.activate
def test_api_mail_domains__fetch_from_dimail__unrelated():
"""
Authenticated users shouldn't be allowed to fetch
a domain from dimail if they are not an owner or admin.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.PENDING,
)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/fetch/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mail_domains__fetch_from_dimail__viewer():
"""
Authenticated users shouldn't be allowed to fetch a domain from dimail
if they are a viewer.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.MailDomainAccessFactory(
user=user,
role=enums.MailDomainRoleChoices.VIEWER,
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/fetch/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.OWNER,
],
)
@responses.activate
def test_api_mail_domains__fetch_from_dimail(role):
"""
Authenticated users should be allowed to fetch a domain
from dimail if they are an owner or admin.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.PENDING,
)
factories.MailDomainAccessFactory(
domain=domain,
user=user,
role=role,
)
assert domain.expected_config is None
assert domain.last_check_details is None
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
json=dimail_fixtures.CHECK_DOMAIN_OK,
status=200,
)
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/spec/"),
json=dimail_fixtures.DOMAIN_SPEC,
status=200,
)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/fetch/",
)
domain.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": str(enums.MailDomainStatusChoices.ENABLED),
"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,
"support_email": domain.support_email,
"last_check_details": dimail_fixtures.CHECK_DOMAIN_OK,
"action_required_details": {},
"expected_config": dimail_fixtures.DOMAIN_SPEC,
}
assert domain.expected_config == dimail_fixtures.DOMAIN_SPEC
assert domain.last_check_details == dimail_fixtures.CHECK_DOMAIN_OK
assert domain.status == enums.MailDomainStatusChoices.ENABLED
@@ -3,16 +3,19 @@ Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "re
"""
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories
from mailbox_manager import enums, factories
from mailbox_manager.tests.fixtures import dimail as dimail_fixtures
pytestmark = pytest.mark.django_db
@responses.activate
def test_api_mail_domains__retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a domain."""
@@ -23,8 +26,11 @@ def test_api_mail_domains__retrieve_anonymous():
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
# Verify no calls were made to dimail API
assert len(responses.calls) == 0
@responses.activate
def test_api_domains__retrieve_non_existing():
"""
Authenticated users should have an explicit error when trying to retrive
@@ -38,8 +44,11 @@ def test_api_domains__retrieve_non_existing():
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "Not found."}
# Verify no calls were made to dimail API
assert len(responses.calls) == 0
@responses.activate
def test_api_mail_domains__retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a domain
@@ -59,6 +68,7 @@ def test_api_mail_domains__retrieve_authenticated_unrelated():
assert response.json() == {"detail": "No MailDomain matches the given query."}
@responses.activate
def test_api_mail_domains__retrieve_authenticated_related():
"""
Authenticated users should be allowed to retrieve a domain
@@ -87,4 +97,123 @@ def test_api_mail_domains__retrieve_authenticated_related():
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 10,
"support_email": domain.support_email,
"last_check_details": None,
"action_required_details": {},
"expected_config": None,
}
@responses.activate
def test_api_mail_domains__retrieve_authenticated_related_with_action_required():
"""
Authenticated users should be allowed to retrieve a domain
to which they have access and which has actions required to be done.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.ACTION_REQUIRED,
last_check_details=dimail_fixtures.CHECK_DOMAIN_BROKEN_EXTERNAL,
)
factories.MailDomainAccessFactory(domain=domain, user=user)
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": domain.status,
"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,
"support_email": domain.support_email,
"last_check_details": dimail_fixtures.CHECK_DOMAIN_BROKEN_EXTERNAL,
"action_required_details": {
"mx": "Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr., "
"or je trouve example-fr.mail.protection.outlook.com.",
},
"expected_config": None,
}
@responses.activate
def test_api_mail_domains__retrieve_authenticated_related_with_ok_status():
"""
Authenticated users should be allowed to retrieve a domain
to which they have access and which has no actions required to be done.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainEnabledFactory(
last_check_details=dimail_fixtures.CHECK_DOMAIN_OK,
)
factories.MailDomainAccessFactory(domain=domain, user=user)
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": domain.status,
"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,
"support_email": domain.support_email,
"last_check_details": dimail_fixtures.CHECK_DOMAIN_OK,
"action_required_details": {},
"expected_config": None,
}
@responses.activate
def test_api_mail_domains__retrieve_authenticated_related_with_failed_status():
"""
Authenticated users should be allowed to retrieve a domain
to which they have access and which has failed status.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.FAILED,
last_check_details=dimail_fixtures.CHECK_DOMAIN_BROKEN_INTERNAL,
)
factories.MailDomainAccessFactory(domain=domain, user=user)
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": domain.status,
"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,
"support_email": domain.support_email,
"last_check_details": dimail_fixtures.CHECK_DOMAIN_BROKEN_INTERNAL,
"action_required_details": {},
"expected_config": None,
}
+19
View File
@@ -255,6 +255,25 @@ CHECK_DOMAIN_OK = {
"cert": {"ok": True, "internal": True, "errors": []},
}
# pylint: disable=line-too-long
DOMAIN_SPEC = [
{"target": "", "type": "mx", "value": "mx.ox.numerique.gouv.fr."},
{
"target": "dimail._domainkey",
"type": "txt",
"value": "v=DKIM1; h=sha256; k=rsa; p=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
},
{"target": "imap", "type": "cname", "value": "imap.ox.numerique.gouv.fr."},
{"target": "smtp", "type": "cname", "value": "smtp.ox.numerique.gouv.fr."},
{
"target": "",
"type": "txt",
"value": "v=spf1 include:_spf.ox.numerique.gouv.fr -all",
},
{"target": "webmail", "type": "cname", "value": "webmail.ox.numerique.gouv.fr."},
]
## TOKEN
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
@@ -18,6 +18,7 @@ from mailbox_manager import enums, factories, models
from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
TOKEN_OK,
response_mailbox_created,
)
@@ -150,3 +151,56 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
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
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.FAILED,
enums.MailDomainStatusChoices.ENABLED,
],
)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_expected_config(client, domain_status):
"""Test admin action to fetch domain expected config from dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=domain_status)
data = {
"action": "fetch_domain_expected_config_from_dimail",
"_selected_action": [domain.id],
}
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
status=status.HTTP_200_OK,
content_type="application/json",
)
url = reverse("admin:mailbox_manager_maildomain_changelist")
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
domain.refresh_from_db()
assert domain.expected_config == DOMAIN_SPEC
@pytest.mark.django_db
def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(client):
"""Test admin action to fetch domain expected config from dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=enums.MailDomainStatusChoices.DISABLED)
data = {
"action": "fetch_domain_expected_config_from_dimail",
"_selected_action": [domain.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
domain.refresh_from_db()
assert domain.expected_config is None
assert "Domains disabled are excluded from fetch" in response.content.decode(
"utf-8"
)
@@ -194,11 +194,13 @@ def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
assert domain.last_check_details == body_content
# call again, should be ok
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
assert domain.last_check_details == body_content
@pytest.mark.parametrize(
@@ -217,12 +219,12 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
"""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
body_domain_broken = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
body=json.dumps(body_domain_broken),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -230,21 +232,23 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain.last_check_details == body_domain_broken
# Support team fixes their part of the problem
# Now domain is OK again
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
body=json.dumps(body_domain_ok),
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
assert domain.last_check_details == body_domain_ok
@pytest.mark.parametrize(
@@ -261,12 +265,12 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_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
body_domain_broken = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
body=json.dumps(body_domain_broken),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -274,7 +278,7 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_content),
body=json.dumps(body_domain_broken),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -282,6 +286,7 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.FAILED
assert domain.last_check_details == body_domain_broken
@pytest.mark.parametrize(
@@ -298,12 +303,12 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_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
body_domain_broken = CHECK_DOMAIN_BROKEN.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
body=json.dumps(body_domain_broken),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -311,27 +316,28 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain.last_check_details == body_domain_broken
# 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
body_domain_broken_internal = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken_internal["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
body=json.dumps(body_domain_broken_internal),
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
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_content),
body=json.dumps(body_domain_ok),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -339,6 +345,7 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
assert domain.last_check_details == body_domain_ok
def test_dimail__enable_pending_mailboxes(caplog):
@@ -391,7 +398,7 @@ def test_dimail__enable_pending_mailboxes(caplog):
)
assert (
caplog.records[2].message
== f"Information for mailbox mock@{domain.name} sent to {mailbox1.secondary_email}."
== f"Information for mailbox mock@{domain.name} sent to {mailbox2.secondary_email}."
)
assert (
caplog.records[4].message
@@ -399,5 +406,5 @@ def test_dimail__enable_pending_mailboxes(caplog):
)
assert (
caplog.records[5].message
== f"Information for mailbox mock@{domain.name} sent to {mailbox2.secondary_email}."
== f"Information for mailbox mock@{domain.name} sent to {mailbox1.secondary_email}."
)
+103 -32
View File
@@ -418,12 +418,20 @@ class DimailAPIClient:
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}"},
verify=True,
timeout=10,
)
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/check/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
verify=True,
timeout=10,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if response.status_code == status.HTTP_200_OK:
return response.json()
return self.raise_exception_for_unexpected_response(response)
@@ -448,43 +456,61 @@ class DimailAPIClient:
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
if dimail_response:
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.last_check_details = dimail_response
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
# 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
)
if all(external_checks.values()) and all(internal_checks.values()):
domain.status = enums.MailDomainStatusChoices.ENABLED
# 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.last_check_details = dimail_response
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()
):
domain.status = enums.MailDomainStatusChoices.FAILED
domain.save()
# 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.last_check_details = dimail_response
domain.save()
elif all(external_checks.values()) and not all(
internal_checks.values()
):
domain.status = enums.MailDomainStatusChoices.FAILED
domain.last_check_details = dimail_response
domain.save()
# if no health check data is stored on the domain, we store it now
if not domain.last_check_details:
domain.last_check_details = dimail_response
domain.save()
return dimail_response
def _get_dimail_checks(self, dimail_response, internal: bool):
@@ -494,3 +520,48 @@ class DimailAPIClient:
if isinstance(value, dict) and value.get("internal") is internal
}
return {key: value.get("ok", False) for key, value in checks.items()}
def fetch_domain_expected_config(self, domain):
"""Send a request to dimail to get domain specification for DNS configuration."""
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/spec/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
verify=True,
timeout=10,
)
except requests.exceptions.ConnectionError as error:
logger.exception(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
return []
if response.status_code == status.HTTP_200_OK:
# format the response to log an error if api response changed
try:
dimail_expected_config = [
{
"target": item["target"],
"type": item["type"],
"value": item["value"],
}
for item in response.json()
]
domain.expected_config = dimail_expected_config
domain.save()
return dimail_expected_config
except KeyError as error:
logger.exception(
"[DIMAIL] spec expected response format changed: %s",
error,
)
return []
else:
logger.exception(
"[DIMAIL] unexpected error : %s %s",
response.status_code,
response.content,
exc_info=False,
)
return []
+51 -39
View File
@@ -323,6 +323,42 @@ class Base(Configuration):
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours to match Agent Connect
# Python loggers configuration (and env var overrides)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
default=True,
@@ -465,6 +501,21 @@ class Base(Configuration):
environ_name="MAIL_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
DNS_PROVISIONING_API_URL = values.Value(
default="https://api.scaleway.com",
environ_name="DNS_PROVISIONING_API_URL",
environ_prefix=None,
)
DNS_PROVISIONING_RESOURCE_ID = values.Value(
default=None,
environ_name="DNS_PROVISIONING_RESOURCE_ID",
environ_prefix=None,
)
DNS_PROVISIONING_API_CREDENTIALS = values.Value(
default=None,
environ_name="DNS_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
# Organizations
ORGANIZATION_REGISTRATION_ID_VALIDATORS = json.loads(
@@ -602,48 +653,11 @@ class Development(Base):
USE_SWAGGER = True
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# this is a dev credentials for mail provisioning API
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"
ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
def __init__(self):
"""In dev, force installs needed for Swagger API."""
# pylint: disable=invalid-name
@@ -691,8 +705,6 @@ class Test(Base):
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"
ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
{
"NAME": "django.core.validators.RegexValidator",
+238 -6
View File
@@ -1,12 +1,19 @@
"""Organization related plugins."""
import logging
import re
from django.conf import settings
from django.utils.text import slugify
import requests
from requests.adapters import HTTPAdapter, Retry
from core.plugins.base import BaseOrganizationPlugin
from mailbox_manager.enums import MailDomainRoleChoices
from mailbox_manager.models import MailDomain, MailDomainAccess
logger = logging.getLogger(__name__)
@@ -27,14 +34,8 @@ class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin):
def get_organization_name_from_results(data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
is_commune = (
result.get("nature_juridique") == "7210"
) # INSEE code for commune
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
if is_commune:
return organization["libelle_commune"].title()
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title()
@@ -76,3 +77,234 @@ class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin):
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
def run_after_grant_access(self, organization_access):
"""After granting an organization access, we don't need to do anything."""
class ApiCall:
"""Encapsulates a call to an external API"""
inputs: dict = {}
method: str = "GET"
base: str = ""
url: str = ""
params: dict = {}
headers: dict = {}
response_data = None
def execute(self):
"""Call the specified API endpoint with supplied parameters and record response"""
if self.method in ("POST", "PATCH"):
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
json=self.params,
headers=self.headers,
timeout=5,
)
else:
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
params=self.params,
headers=self.headers,
timeout=5,
)
self.response_data = response.json()
logger.info(
"API call: %s %s %s %s",
self.method,
self.url,
self.params,
self.response_data,
)
class CommuneCreation(BaseOrganizationPlugin):
"""
This plugin handles setup tasks for French communes.
"""
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def get_organization_name_from_results(self, 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"
if commune:
return result["siege"]["libelle_commune"].title()
logger.warning("Not a commune: SIRET %s", siret)
return None
def dns_call(self, spec):
"""Call to add a DNS record"""
zone_name = self.zone_name(spec.inputs["name"])
records = [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec.response_data
]
result = ApiCall()
result.method = "PATCH"
result.base = "https://api.scaleway.com"
result.url = f"/domain/v2beta1/dns-zones/{zone_name}/records"
result.params = {"changes": [{"add": {"records": records}}]}
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
return result
def normalize_name(self, name: str) -> str:
"""Map the name to a standard form"""
name = re.sub("'", "-", name)
return slugify(name)
def zone_name(self, name: str) -> str:
"""Derive the zone name from the commune name"""
normalized = self.normalize_name(name)
return f"{normalized}.collectivite.fr"
def complete_commune_creation(self, name: str) -> ApiCall:
"""Specify the tasks to be completed after a commune is created."""
inputs = {"name": self.normalize_name(name)}
create_zone = ApiCall()
create_zone.method = "POST"
create_zone.base = "https://api.scaleway.com"
create_zone.url = "/domain/v2beta1/dns-zones"
create_zone.params = {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": "collectivite.fr",
"subdomain": inputs["name"],
}
create_zone.headers = {
"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS
}
zone_name = self.zone_name(inputs["name"])
create_domain = ApiCall()
create_domain.method = "POST"
create_domain.base = settings.MAIL_PROVISIONING_API_URL
create_domain.url = "/domains/"
create_domain.params = {
"name": zone_name,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": zone_name,
}
create_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
spec_domain = ApiCall()
spec_domain.inputs = inputs
spec_domain.base = settings.MAIL_PROVISIONING_API_URL
spec_domain.url = f"/domains/{zone_name}/spec"
spec_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_zone, create_domain, spec_domain]
def complete_zone_creation(self, spec_call):
"""Specify the tasks to be performed to set up the zone."""
return self.dns_call(spec_call)
def run_after_create(self, organization):
"""After creating an organization, update the organization name."""
logger.info("In CommuneCreation")
if not organization.registration_id_list:
# No registration ID to convert...
return
# In the nominal case, there is only one registration ID because
# the organization has been created from it.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.get(self._api_url.format(siret=siret), timeout=10)
response.raise_for_status()
data = response.json()
name = self.get_organization_name_from_results(data, siret)
# Not a commune ?
if not name:
return
except requests.RequestException as exc:
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
return
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
zone_name = self.zone_name(name)
support = "support-regie@numerique.gouv.fr"
MailDomain.objects.get_or_create(name=zone_name, support_email=support)
# Compute and execute the rest of the process
tasks = self.complete_commune_creation(name)
for task in tasks:
task.execute()
last_task = self.complete_zone_creation(tasks[-1])
last_task.execute()
def complete_grant_access(self, sub, zone_name):
"""Specify the tasks to be completed after making a user admin"""
create_user = ApiCall()
create_user.method = "POST"
create_user.base = settings.MAIL_PROVISIONING_API_URL
create_user.url = "/users/"
create_user.params = {
"name": sub,
"password": "no",
"is_admin": False,
"perms": [],
}
create_user.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
grant_access = ApiCall()
grant_access.method = "POST"
grant_access.base = settings.MAIL_PROVISIONING_API_URL
grant_access.url = "/allows/"
grant_access.params = {
"user": sub,
"domain": zone_name,
}
grant_access.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_user, grant_access]
def run_after_grant_access(self, organization_access):
"""After granting an organization access, check for needed domain access grant."""
orga = organization_access.organization
user = organization_access.user
zone_name = self.zone_name(orga.name)
try:
domain = MailDomain.objects.get(name=zone_name)
except MailDomain.DoesNotExist:
domain = None
if domain:
MailDomainAccess.objects.create(
domain=domain, user=user, role=MailDomainRoleChoices.OWNER
)
tasks = self.complete_grant_access(user.sub, zone_name)
for task in tasks:
task.execute()
@@ -0,0 +1,224 @@
"""Tests for the CommuneCreation plugin."""
from django.conf import settings
import pytest
import responses
from plugins.organizations import ApiCall, CommuneCreation
pytestmark = pytest.mark.django_db
def test_extract_name_from_org_data_when_commune():
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
plugin = CommuneCreation()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
def test_api_call_execution():
"""Test that calling execute() faithfully executes the API call"""
task = ApiCall()
task.method = "POST"
task.base = "https://some_host"
task.url = "some_url"
task.params = {"some_key": "some_value"}
task.headers = {"Some-Header": "Some-Header-Value"}
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
url="https://some_host/some_url",
body='{"some_key": "some_value"}',
content_type="application/json",
headers={"Some-Header": "Some-Header-Value"},
)
task.execute()
def test_tasks_on_commune_creation_include_zone_creation():
"""Test the first task in commune creation: creating the DNS sub-zone"""
plugin = CommuneCreation()
name = "Varzy"
tasks = plugin.complete_commune_creation(name)
assert tasks[0].base == "https://api.scaleway.com"
assert tasks[0].url == "/domain/v2beta1/dns-zones"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": "collectivite.fr",
"subdomain": "varzy",
}
assert tasks[0].headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
def test_tasks_on_commune_creation_include_dimail_domain_creation():
"""Test the second task in commune creation: creating the domain in Dimail"""
plugin = CommuneCreation()
name = "Merlaut"
tasks = plugin.complete_commune_creation(name)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/domains/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"name": "merlaut.collectivite.fr",
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": "merlaut.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_tasks_on_commune_creation_include_fetching_spec():
"""Test the third task in commune creation: asking Dimail for the spec"""
plugin = CommuneCreation()
name = "Loc-Eguiner"
tasks = plugin.complete_commune_creation(name)
assert tasks[2].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[2].url == "/domains/loc-eguiner.collectivite.fr/spec"
assert tasks[2].method == "GET"
assert (
tasks[2].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_tasks_on_commune_creation_include_dns_records():
"""Test the next several tasks in commune creation: creating records"""
plugin = CommuneCreation()
name = "Abidos"
spec_response = [
{"target": "", "type": "mx", "value": "mx.dev.ox.numerique.gouv.fr."},
{
"target": "dimail._domainkey",
"type": "txt",
"value": "v=DKIM1; h=sha256; k=rsa; p=MIICIjANB<truncated>AAQ==",
},
{"target": "imap", "type": "cname", "value": "imap.dev.ox.numerique.gouv.fr."},
{"target": "smtp", "type": "cname", "value": "smtp.dev.ox.numerique.gouv.fr."},
{
"target": "",
"type": "txt",
"value": "v=spf1 include:_spf.dev.ox.numerique.gouv.fr -all",
},
{
"target": "webmail",
"type": "cname",
"value": "webmail.dev.ox.numerique.gouv.fr.",
},
]
tasks = plugin.complete_commune_creation(name)
tasks[2].response_data = spec_response
expected = {
"changes": [
{
"add": {
"records": [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec_response
]
}
}
]
}
zone_call = plugin.complete_zone_creation(tasks[2])
assert zone_call.params == expected
assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records"
assert (
zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
)
def test_tasks_on_grant_access():
"""Test the final tasks after making user admin of an org"""
plugin = CommuneCreation()
tasks = plugin.complete_grant_access("some-sub", "mezos.collectivite.fr")
assert tasks[0].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[0].url == "/users/"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"name": "some-sub",
"password": "no",
"is_admin": False,
"perms": [],
}
assert (
tasks[0].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/allows/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"user": "some-sub",
"domain": "mezos.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_normalize_name():
"""Test name normalization"""
plugin = CommuneCreation()
assert plugin.normalize_name("Asnières-sur-Saône") == "asnieres-sur-saone"
assert plugin.normalize_name("Bâgé-le-Châtel") == "bage-le-chatel"
assert plugin.normalize_name("Courçais") == "courcais"
assert plugin.normalize_name("Moÿ-de-l'Aisne") == "moy-de-l-aisne"
assert plugin.normalize_name("Salouël") == "salouel"
assert (
plugin.normalize_name("Bors (Canton de Tude-et-Lavalette)")
== "bors-canton-de-tude-et-lavalette"
)
def test_zone_name():
"""Test transforming a commune name to a sub-zone of collectivite.fr"""
plugin = CommuneCreation()
assert plugin.zone_name("Bâgé-le-Châtel") == "bage-le-chatel.collectivite.fr"
@@ -6,8 +6,6 @@ import responses
from core.models import Organization
from core.plugins.loader import get_organization_plugins
from plugins.organizations import NameFromSiretOrganizationPlugin
pytestmark = pytest.mark.django_db
@@ -139,37 +137,6 @@ def test_organization_plugins_run_after_create_name_already_set(
assert organization.name == "Magic WOW"
def test_extract_name_from_org_data_when_commune(
organization_plugins_settings,
):
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
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,
+20 -14
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.10.1"
version = "1.12.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,19 +25,19 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.36.11",
"boto3==1.36.21",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-parler==2.3",
"django-treebeard==4.7.1",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages==1.14.4",
"django-storages==1.14.5",
"django-timezone-field>=5.1",
"django==5.1.5",
"django==5.1.6",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
@@ -48,18 +48,18 @@ dependencies = [
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"joserfc==1.0.2",
"joserfc==1.0.3",
"requests==2.32.3",
"sentry-sdk[django]==2.20.0",
"whitenoise==6.8.2",
"sentry-sdk[django]==2.21.0",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
]
[project.urls]
"Bug Tracker" = "https://github.com/numerique-gouv/people/issues/new"
"Changelog" = "https://github.com/numerique-gouv/people/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/numerique-gouv/people"
"Repository" = "https://github.com/numerique-gouv/people"
"Bug Tracker" = "https://github.com/suitenumerique/people/issues/new"
"Changelog" = "https://github.com/suitenumerique/people/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/suitenumerique/people"
"Repository" = "https://github.com/suitenumerique/people"
[project.optional-dependencies]
dev = [
@@ -72,12 +72,12 @@ dev = [
"pylint-django==2.6.1",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest-django==4.10.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.4",
"ruff==0.9.6",
"types-requests==2.32.0.20241016",
"freezegun==1.5.1",
]
@@ -140,6 +140,12 @@ python_files = [
"test_*.py",
"tests.py",
]
filterwarnings = [
# This one can be removed when upgrading to Django 6.0
'ignore:The FORMS_URLFIELD_ASSUME_HTTPS transitional setting is deprecated.',
# This one can be removed after upgrading DRF to 3.16
"ignore:Converter 'drf_format_suffix'"
]
[tool.coverage.run]
branch = true
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.10.1",
"version": "1.12.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,20 +16,20 @@
},
"dependencies": {
"@gouvfr-lasuite/integration": "1.0.2",
"@hookform/resolvers": "3.10.0",
"@hookform/resolvers": "4.0.0",
"@openfun/cunningham-react": "3.0.0",
"@tanstack/react-query": "5.66.0",
"i18next": "24.2.2",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.6",
"next": "15.1.7",
"react": "*",
"react-dom": "*",
"react-hook-form": "7.54.2",
"react-i18next": "15.4.0",
"react-select": "5.10.0",
"sass": "1.83.4",
"styled-components": "6.1.14",
"sass": "1.84.0",
"styled-components": "6.1.15",
"zod": "3.24.1",
"zustand": "5.0.3"
},
@@ -53,7 +53,7 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.4.2",
"prettier": "3.5.0",
"stylelint": "16.14.1",
"stylelint-config-standard": "37.0.0",
"stylelint-prettier": "5.0.3",
@@ -15,7 +15,7 @@ export const Modal: React.FC<ModalProps> = ({ children, ...props }) => {
/**
* @description used to prevent elements to be navigable by keyboard when only a DOM mutation causes the elements to be
* in the document
* @see https://github.com/numerique-gouv/people/pull/379
* @see https://github.com/suitenumerique/people/pull/379
*/
export const usePreventFocusVisible = (elements: string[]) => {
useEffect(() => {
@@ -62,7 +62,7 @@ export const LanguagePicker = () => {
/**
* @description prevent select div to receive focus on keyboard navigation so the focus goes directly to inner button
* @see https://github.com/numerique-gouv/people/pull/379
* @see https://github.com/suitenumerique/people/pull/379
*/
useEffect(() => {
if (!document) {
@@ -99,6 +99,7 @@ describe('MailDomainAccessesPage', () => {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -25,6 +25,7 @@ describe('AccessAction', () => {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -31,6 +31,7 @@ describe('AccessesContent', () => {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -26,6 +26,7 @@ const mockMailDomain: MailDomain = {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
manage_accesses: true,
get: true,
@@ -36,6 +36,7 @@ describe('ModalDelete', () => {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -0,0 +1,140 @@
import { VariantType } from '@openfun/cunningham-react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { MailDomain } from '@/features/mail-domains/domains';
import { AppWrapper } from '@/tests/utils';
import { MailDomainView } from '../components/MailDomainView';
const mockMailDomain: MailDomain = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'example.com',
status: 'action_required',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
slug: 'example-com',
support_email: 'support@example.com',
abilities: {
delete: false,
manage_accesses: true,
get: true,
patch: true,
put: true,
post: true,
},
action_required_details: {
mx: 'Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr....',
},
expected_config: [
{ target: '', type: 'mx', value: 'mx.ox.numerique.gouv.fr.' },
{
target: 'dimail._domainkey',
type: 'txt',
value:
'v=DKIM1; h=sha256; k=rsa; p=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
},
{ target: 'imap', type: 'cname', value: 'imap.ox.numerique.gouv.fr.' },
{ target: 'smtp', type: 'cname', value: 'smtp.ox.numerique.gouv.fr.' },
{
target: '',
type: 'txt',
value: 'v=spf1 include:_spf.ox.numerique.gouv.fr -all',
},
{
target: 'webmail',
type: 'cname',
value: 'webmail.ox.numerique.gouv.fr.',
},
],
};
const toast = jest.fn();
jest.mock('@openfun/cunningham-react', () => ({
...jest.requireActual('@openfun/cunningham-react'),
useToastProvider: () => ({
toast,
}),
}));
describe('<MailDomainView />', () => {
const apiUrl = `end:/mail-domains/${mockMailDomain.slug}/fetch/`;
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
it('display action required button and open modal with information when domain status is action_required', () => {
render(<MailDomainView mailDomain={mockMailDomain} />, {
wrapper: AppWrapper,
});
// Check if action required button is displayed
const actionButton = screen.getByText('Actions required');
expect(actionButton).toBeInTheDocument();
// Click the button and verify modal content
fireEvent.click(actionButton);
// Verify modal title and content
expect(screen.getByText('Required actions on domain')).toBeInTheDocument();
expect(
screen.getByText(
/Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr./i,
),
).toBeInTheDocument();
// Verify DNS configuration section
expect(
screen.getByText(/DNS Configuration Required:/i),
).toBeInTheDocument();
expect(screen.getByText(/imap.ox.numerique.gouv.fr./i)).toBeInTheDocument();
expect(
screen.getByText(/webmail.ox.numerique.gouv.fr./i),
).toBeInTheDocument();
});
it('allows re-running domain check when clicking re-run button', async () => {
// Mock the fetch call
fetchMock.postOnce(apiUrl, {
status: 200,
body: mockMailDomain,
});
render(
<MailDomainView
mailDomain={mockMailDomain}
onMailDomainUpdate={jest.fn()}
/>,
{ wrapper: AppWrapper },
);
// Check if action required button is displayed
const actionButton = screen.getByText('Actions required');
expect(actionButton).toBeInTheDocument();
// Click the button and verify modal content
fireEvent.click(actionButton);
// Verify modal title and content
expect(screen.getByText('Required actions on domain')).toBeInTheDocument();
expect(
screen.getByText(
/Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr./,
),
).toBeInTheDocument();
// Find and click re-run button
const reRunButton = screen.getByText('Re-run check');
fireEvent.click(reRunButton);
await waitFor(() => {
expect(fetchMock.called(apiUrl)).toBeTruthy();
});
expect(toast).toHaveBeenCalledWith(
'Domain data fetched successfully',
VariantType.SUCCESS,
);
});
});
@@ -19,6 +19,7 @@ describe('ModalAddMailDomain', () => {
modalElement: screen.getByText('Add a mail domain'),
formTag: screen.getByTitle('Mail domain addition form'),
inputName: screen.getByLabelText(/Domain name/i),
inputSupportEmail: screen.getByLabelText(/Support email address/i),
buttonCancel: screen.getByRole('button', { name: /Cancel/i, hidden: true }),
buttonSubmit: screen.getByRole('button', {
name: /Add the domain/i,
@@ -37,12 +38,19 @@ describe('ModalAddMailDomain', () => {
it('renders all the elements', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { modalElement, formTag, inputName, buttonCancel, buttonSubmit } =
getElements();
const {
modalElement,
formTag,
inputName,
inputSupportEmail,
buttonCancel,
buttonSubmit,
} = getElements();
expect(modalElement).toBeVisible();
expect(formTag).toBeVisible();
expect(inputName).toBeVisible();
expect(inputSupportEmail).toBeVisible();
expect(screen.getByText('Example: saint-laurent.fr')).toBeVisible();
expect(buttonCancel).toBeVisible();
expect(buttonSubmit).toBeVisible();
@@ -104,9 +112,10 @@ describe('ModalAddMailDomain', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
const { inputName, inputSupportEmail, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputSupportEmail, 'support@domain.fr');
await user.click(buttonSubmit);
@@ -114,6 +123,7 @@ describe('ModalAddMailDomain', () => {
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
name: 'domain.fr',
support_email: 'support@domain.fr',
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
@@ -123,29 +133,6 @@ describe('ModalAddMailDomain', () => {
expect(mockPush).toHaveBeenCalledWith(`/mail-domains/domainfr`);
});
it('submits the form on key enter press', async () => {
fetchMock.mock(`end:mail-domains/`, 201);
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputName, '{enter}');
expect(fetchMock.lastUrl()).toContain('/mail-domains/');
expect(fetchMock.lastOptions()).toEqual({
body: JSON.stringify({
name: 'domain.fr',
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
});
it('displays right error message error when maildomain name is already used', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 400,
@@ -158,10 +145,10 @@ describe('ModalAddMailDomain', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
const { inputName, inputSupportEmail, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputSupportEmail, 'support@domain.fr');
await user.click(buttonSubmit);
await waitFor(() => {
@@ -175,6 +162,7 @@ describe('ModalAddMailDomain', () => {
expect(inputName).toHaveFocus();
await user.type(inputName, 'domain2.fr');
expect(buttonSubmit).toBeEnabled();
});
@@ -190,9 +178,10 @@ describe('ModalAddMailDomain', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
const { inputName, inputSupportEmail, buttonSubmit } = getElements();
await user.type(inputName, 'domainfr');
await user.type(inputSupportEmail, 'support@domain.fr');
await user.click(buttonSubmit);
@@ -220,9 +209,10 @@ describe('ModalAddMailDomain', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
const { inputName, inputSupportEmail, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.type(inputSupportEmail, 'support@domain.fr');
await user.click(buttonSubmit);
@@ -0,0 +1,76 @@
import fetchMock from 'fetch-mock';
import { APIError } from '@/api';
import { fetchMailDomain } from '@/features/mail-domains/domains/api/useFetchMailDomain';
import { MailDomain } from '@/features/mail-domains/domains/types';
const mockMailDomain: MailDomain = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'example.com',
status: 'enabled',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
slug: 'example-com',
support_email: 'support@example.com',
abilities: {
delete: false,
manage_accesses: true,
get: true,
patch: true,
put: true,
post: true,
},
action_required_details: {
mx: 'Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr.',
},
expected_config: [
{ target: '', type: 'mx', value: 'mx.ox.numerique.gouv.fr.' },
{
target: 'dimail._domainkey',
type: 'txt',
value: 'v=DKIM1; h=sha256; k=rsa; p=X...X',
},
{ target: 'imap', type: 'cname', value: 'imap.ox.numerique.gouv.fr.' },
{ target: 'smtp', type: 'cname', value: 'smtp.ox.numerique.gouv.fr.' },
{
target: '',
type: 'txt',
value: 'v=spf1 include:_spf.ox.numerique.gouv.fr -all',
},
{
target: 'webmail',
type: 'cname',
value: 'webmail.ox.numerique.gouv.fr.',
},
],
};
describe('fetchMailDomain', () => {
afterEach(() => {
fetchMock.restore();
});
it('fetch the domain successfully', async () => {
fetchMock.postOnce('end:/mail-domains/example-slug/fetch/', {
status: 200,
body: mockMailDomain,
});
const result = await fetchMailDomain('example-slug');
expect(result).toEqual(mockMailDomain);
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastUrl()).toContain('/mail-domains/example-slug/fetch/');
});
it('throw an error when the domain is not found', async () => {
fetchMock.postOnce('end:/mail-domains/example-slug/fetch/', {
status: 404,
body: { cause: ['Domain not found'] },
});
await expect(fetchMailDomain('example-slug')).rejects.toThrow(APIError);
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastUrl()).toContain('/mail-domains/example-slug/fetch/');
});
});
@@ -8,16 +8,16 @@ import { KEY_LIST_MAIL_DOMAIN } from './useMailDomains';
export interface AddMailDomainParams {
name: string;
supportEmail: string;
}
export const addMailDomain = async (
name: AddMailDomainParams['name'],
): Promise<MailDomain> => {
export const addMailDomain = async ({
name,
supportEmail,
}: AddMailDomainParams): Promise<MailDomain> => {
const response = await fetchAPI(`mail-domains/`, {
method: 'POST',
body: JSON.stringify({
name,
}),
body: JSON.stringify({ name, support_email: supportEmail }),
});
if (!response.ok) {
@@ -38,7 +38,7 @@ export const useAddMailDomain = ({
onError: (error: APIError) => void;
}) => {
const queryClient = useQueryClient();
return useMutation<MailDomain, APIError, string>({
return useMutation<MailDomain, APIError, AddMailDomainParams>({
mutationFn: addMailDomain,
onSuccess: (data) => {
void queryClient.invalidateQueries({
@@ -0,0 +1,38 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { MailDomain } from '../types';
export const fetchMailDomain = async (slug: string): Promise<MailDomain> => {
const response = await fetchAPI(`mail-domains/${slug}/fetch/`, {
method: 'POST',
});
if (!response.ok) {
throw new APIError(
'Failed to fetch domain from Dimail',
await errorCauses(response),
);
}
return response.json() as Promise<MailDomain>;
};
export const useFetchFromDimail = ({
onSuccess,
onError,
}: {
onSuccess: (data: MailDomain) => void;
onError: (error: APIError) => void;
}) => {
return useMutation<MailDomain, APIError, string>({
mutationFn: fetchMailDomain,
onSuccess: (data) => {
onSuccess(data);
},
onError: (error) => {
onError(error);
},
});
};
@@ -1,3 +1,10 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import * as React from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,11 +16,17 @@ import MailDomainsLogo from '@/features/mail-domains/assets/mail-domains-logo.sv
import { MailDomain, Role } from '@/features/mail-domains/domains';
import { MailDomainsContent } from '@/features/mail-domains/mailboxes';
import { useFetchFromDimail } from '../api/useFetchMailDomain';
type Props = {
mailDomain: MailDomain;
onMailDomainUpdate?: (updatedDomain: MailDomain) => void;
};
export const MailDomainView = ({ mailDomain }: Props) => {
export const MailDomainView = ({ mailDomain, onMailDomainUpdate }: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [showModal, setShowModal] = React.useState(false);
const currentRole = mailDomain.abilities.delete
? Role.OWNER
: mailDomain.abilities.manage_accesses
@@ -41,28 +54,177 @@ export const MailDomainView = ({ mailDomain }: Props) => {
];
}, [t, currentRole, mailDomain]);
const handleShowModal = () => {
setShowModal(true);
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
toast(t('copy done'), VariantType.SUCCESS);
};
const { mutate: fetchMailDomain } = useFetchFromDimail({
onSuccess: (data: MailDomain) => {
console.info('fetchMailDomain success', data);
setShowModal(false);
toast(t('Domain data fetched successfully'), VariantType.SUCCESS);
onMailDomainUpdate?.(data);
},
onError: () => {
console.error('fetchMailDomain error');
toast(t('Failed to fetch domain data'), VariantType.ERROR);
},
});
return (
<Box $padding="big">
<Box
$width="100%"
$direction="row"
$align="center"
$gap="2.25rem"
$justify="center"
>
<Box
$direction="row"
$justify="center"
$margin={{ bottom: 'big' }}
$gap="0.5rem"
<>
{showModal && (
<Modal
isOpen
size={ModalSize.EXTRA_LARGE}
onClose={() => setShowModal(false)}
title={t('Required actions on domain')}
>
<MailDomainsLogo aria-hidden="true" />
<Text $margin="none" as="h3" $size="h3">
{mailDomain?.name}
</Text>
<p>
{t(
'The domain is currently in action required status. Please take the necessary actions to resolve those following issues.',
)}
</p>
<h3>{t('Actions required detail')}</h3>
<pre>
{mailDomain.action_required_details &&
Object.entries(mailDomain.action_required_details).map(
([check, value], index) => (
<ul key={`action-required-list-${index}`}>
<li key={`action-required-${index}`}>
<b>{check}</b>: {value}
</li>
</ul>
),
)}
</pre>
{mailDomain.expected_config && (
<Box $margin={{ bottom: 'medium' }}>
<h3>{t('DNS Configuration Required:')}</h3>
<pre>
<div
style={{
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
}}
>
{t('Add the following DNS values:')}
<ul>
{mailDomain.expected_config.map((item, index) => (
<li
key={`dns-record-${index}`}
style={{ marginBottom: '10px' }}
>
{item.target && (
<>
<b>{item.target.toUpperCase()}</b> -{' '}
</>
)}
<b>{item.type.toUpperCase()}</b> {t('with value:')}{' '}
<span style={{ backgroundColor: '#d4e5f5' }}>
{item.value}
</span>
<button
style={{
padding: '2px 5px',
marginLeft: '10px',
backgroundColor: '#cccccc',
border: 'none',
color: 'white',
cursor: 'pointer',
fontWeight: '500',
borderRadius: '5px',
}}
onClick={() => {
void copyToClipboard(item.value);
}}
>
{t('Copy')}
</button>
</li>
))}
</ul>
</div>
</pre>
</Box>
)}
<pre>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Button
onClick={() => {
void fetchMailDomain(mailDomain.slug);
}}
>
{t('Re-run check')}
</Button>
</div>
</pre>
</Modal>
)}
<Box $padding="big">
<Box
$width="100%"
$direction="row"
$align="center"
$gap="2.25rem"
$justify="center"
>
<Box
$direction="row"
$justify="center"
$margin={{ bottom: 'big' }}
$gap="0.5rem"
>
<MailDomainsLogo aria-hidden="true" />
<Text $margin="none" as="h3" $size="h3">
{mailDomain?.name}
</Text>
{/* TODO: remove when pending status will be removed */}
{mailDomain?.status === 'pending' && (
<button
onClick={handleShowModal}
style={{
padding: '5px 10px',
marginLeft: '10px',
backgroundColor: '#cccccc',
border: 'none',
color: 'white',
cursor: 'pointer',
fontWeight: '500',
borderRadius: '5px',
}}
data-modal="mail-domain-status"
>
{t('Pending')}
</button>
)}
{mailDomain?.status === 'action_required' && (
<button
onClick={handleShowModal}
style={{
padding: '5px 10px',
marginLeft: '10px',
backgroundColor: '#f37802',
border: 'none',
color: 'white',
cursor: 'pointer',
fontWeight: '500',
borderRadius: '5px',
}}
data-modal="mail-domain-status"
>
{t('Actions required')}
</button>
)}
</Box>
</Box>
<CustomTabs tabs={tabs} />
</Box>
<CustomTabs tabs={tabs} />
</Box>
</>
);
};
@@ -6,6 +6,7 @@ import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { APIError } from '@/api';
import { parseAPIError } from '@/api/parseAPIError';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
@@ -23,12 +24,14 @@ export const ModalAddMailDomain = () => {
const addMailDomainValidationSchema = z.object({
name: z.string().min(1, t('Example: saint-laurent.fr')),
supportEmail: z.string().email(t('Please enter a valid email address')),
});
const methods = useForm<{ name: string }>({
const methods = useForm<{ name: string; supportEmail: string }>({
delayError: 0,
defaultValues: {
name: '',
supportEmail: '',
},
mode: 'onChange',
reValidateMode: 'onChange',
@@ -39,7 +42,7 @@ export const ModalAddMailDomain = () => {
onSuccess: (mailDomain) => {
router.push(`/mail-domains/${mailDomain.slug}`);
},
onError: (error) => {
onError: (error: APIError) => {
const unhandledCauses = parseAPIError({
error,
errorParams: [
@@ -87,8 +90,8 @@ export const ModalAddMailDomain = () => {
const onSubmitCallback = (event: React.FormEvent) => {
event.preventDefault();
void methods.handleSubmit(({ name }) => {
void addMailDomain(name);
void methods.handleSubmit(({ name, supportEmail }) => {
void addMailDomain({ name, supportEmail });
})();
};
@@ -167,6 +170,27 @@ export const ModalAddMailDomain = () => {
/>
)}
/>
<Box $margin={{ vertical: '10px' }}>
<Controller
control={methods.control}
name="supportEmail"
render={({ fieldState }) => (
<Input
aria-invalid={!!fieldState.error}
aria-required
required
label={t('Support email address')}
state={fieldState.error ? 'error' : 'default'}
text={
fieldState?.error?.message
? fieldState.error.message
: t('E.g. : support@example.fr')
}
{...methods.register('supportEmail')}
/>
)}
/>
</Box>
</form>
{isPending && (
@@ -31,6 +31,8 @@ export const PanelMailDomains = ({ mailDomain }: MailDomainProps) => {
return t('[disabled]');
case 'failed':
return t('[failed]');
case 'action_required':
return t('[action required]');
}
};
@@ -6,7 +6,9 @@ export interface MailDomain {
created_at: string;
updated_at: string;
slug: string;
status: 'pending' | 'enabled' | 'failed' | 'disabled';
status: 'pending' | 'enabled' | 'failed' | 'disabled' | 'action_required';
support_email: string;
action_required_details?: Record<string, string>;
abilities: {
get: boolean;
patch: boolean;
@@ -15,6 +17,11 @@ export interface MailDomain {
delete: boolean;
manage_accesses: boolean;
};
expected_config?: Array<{
target: string;
type: string;
value: string;
}>;
}
export enum Role {
@@ -14,6 +14,7 @@ const mockMailDomain: MailDomain = {
name: 'example.com',
slug: 'example-com',
status: 'enabled',
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -31,6 +32,7 @@ const mockMailDomainAsViewer: MailDomain = {
name: 'example.com',
slug: 'example-com',
status: 'enabled',
support_email: 'support@example.com',
abilities: {
get: true,
patch: false,
@@ -14,6 +14,7 @@ const mockMailDomain: MailDomain = {
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
support_email: 'support@example.com',
abilities: {
get: true,
patch: true,
@@ -1,4 +1,15 @@
{
"de": {
"translation": {
"Accessibility: non-compliant": "Barrierefreiheit: nicht kompatibel",
"Cancel": "",
"Language": "Sprache",
"Legal Notice": "Impressum",
"Logout": "Abmelden",
"No Username": "Kein Benutzername",
"Personal data and cookies": "Personenbezogene Daten und Cookies"
}
},
"en": {
"translation": {
"{{count}} member_many": "{{count}} members",
@@ -14,22 +25,28 @@
"Accesses list card": "Carte de la liste des accès",
"Accessibility statement": "Déclaration d'accessibilité",
"Accessibility: non-compliant": "Accessibilité : non conforme",
"Actions required": "Actions requises",
"Actions required detail": "Détail des actions requises",
"Add a mail domain": "Ajouter un nom de domaine",
"Add a member": "Ajouter un membre",
"Add a team": "Ajouter un groupe",
"Add members to the team": "Ajouter des membres à l'équipe",
"Add the domain": "Ajouter le domaine",
"Add the following DNS values:": "Ajouter les valeurs DNS suivantes :",
"Add to group": "Ajouter au groupe",
"Address: National Agency for Territorial Cohesion - 20, avenue de Ségur TSA 10717 75 334 Paris Cedex 07 Paris": "Adresse : Agence Nationale de la Cohésion des Territoires - 20, avenue de Ségur TSA 10717 75 334 Paris Cedex 07",
"Administration": "Administration",
"Administrator": "Administrateur",
"All fields are mandatory.": "Tous les champs sont obligatoires.",
"Are you sure you want to delete {{teamName}} team?": "Êtes-vous sûr de vouloir supprimer le groupe {{teamName}}?",
"Are you sure you want to disable this mailbox? This action results in the deletion of the calendar, address book, etc.": "Êtes-vous sûr de vouloir désactiver cette boîte mail ? Cette action entraîne la suppression du calendrier, du carnet d'adresses, etc.",
"Are you sure you want to remove this access from the {{domain}} domain?": "Voulez-vous vraiment retirer cet accès du domaine {{domain}} ?",
"Are you sure you want to remove this member from the {{team}} group?": "Voulez-vous vraiment retirer ce membre du groupe {{team}} ?",
"Back to home page": "Retour à l'accueil",
"Cancel": "Annuler",
"Choose a role": "Choisissez un rôle",
"Click on mailbox to view details": "Cliquez sur la boîte mail pour voir les détails",
"Click on team to view details": "Cliquez sur l'équipe pour voir les détails",
"Close the mail domains panel": "Fermer le panneau des domaines de messagerie",
"Close the modal": "Fermer la modale",
"Close the teams panel": "Fermer le panneau des groupes",
@@ -39,6 +56,7 @@
"Content modal to delete the team": "Contenu modal pour supprimer le groupe",
"Content modal to update the team": "Contenu modal pour mettre à jour le groupe",
"Cookies placed": "Cookies déposés",
"Copy": "Copier",
"Copyright": "Copyright",
"Create a mailbox": "Créer une boîte mail",
"Create a mailbox in {{name}} domain": "Créer une boîte mail dans le domaine {{name}}",
@@ -49,20 +67,28 @@
"Create the team": "Créer le groupe",
"Create your first team by clicking on the \"Create a new team\" button.": "Créez votre premier groupe en cliquant sur le bouton \"Créer un nouveau groupe\".",
"Created at": "Créé le",
"DNS Configuration Required:": "Configuration DNS requise :",
"Declaration established on June 25, 2024.": "Établie le 25 juin 2024.",
"Defender of Rights - Free response - 71120 75342 Paris CEDEX 07": "Défenseur des droits\nLibre réponse 71120 75342 Paris CEDEX 07",
"Delete the team": "Supprimer le groupe",
"Deleting the {{teamName}} team": "Suppression du groupe {{teamName}}",
"Disable": "Désactiver",
"Disable mailbox": "Désactiver la boîte mail",
"Domain data fetched successfully": "Les données de domaine ont été récupérées avec succès",
"Domain name": "Nom de domaine",
"E-mail:": "E-mail:",
"E.g. : jean.dupont@mail.fr": "Ex. : jean.dupont@mail.fr",
"E.g. : support@example.fr": "Par exemple : support@example.fr",
"Email address prefix": "Préfixe de l'adresse mail",
"Emails": "Emails",
"Empty team icon": "Icône équipe vide",
"Enable mailbox": "Activer la boîte mail",
"Enter the new name of the selected team": "Entrez le nouveau nom du groupe sélectionné",
"Example: saint-laurent.fr": "Exemple : saint-laurent.fr",
"Failed to add {{name}} in the team": "Impossible d'ajouter {{name}} au groupe",
"Failed to create the invitation for {{email}}": "Impossible de créer l'invitation pour {{email}}",
"Failed to fetch domain data": "Impossible de récupérer les données du domaine",
"Failed to update mailbox status": "Impossible de mettre à jour le statut de la boîte mail",
"Filter member list": "Filtrer la liste des membres",
"Find a member to add to the team": "Trouver un membre à ajouter au groupe",
"First name": "Prénom",
@@ -121,13 +147,16 @@
"Open the teams panel": "Ouvrir le panneau des groupes",
"Ouch!": "Aïe !",
"Owner": "Propriétaire",
"Pending": "En attente",
"Personal data and cookies": "Données personnelles et cookies",
"Please enter a valid email address": "Merci de saisir une adresse e-mail valide",
"Please enter a valid email address.\nE.g. : jean.dupont@mail.fr": "Veuillez entrer une adresse e-mail valide.\nEx. : jean.dupont@mail.fr",
"Please enter your first name": "Veuillez saisir votre prénom",
"Please enter your last name": "Veuillez saisir votre nom",
"Publication Director": "Directeur de la publication",
"Publisher": "Éditeur",
"Radio buttons to update the roles": "Boutons radio pour mettre à jour les rôles",
"Re-run check": "Ré-exécuter la vérification",
"Remedy": "Voie de recours",
"Remove from domain": "Retirer du domaine",
"Remove from group": "Retirer du groupe",
@@ -135,6 +164,7 @@
"Remove from the group": "Retirer du groupe",
"Remove this access from the domain": "Retirer cet accès du domaine",
"Remove this member from the group": "Retirer le membre du groupe",
"Required actions on domain": "Actions requises sur le domaine",
"Roles": "Rôles",
"Régie": "Régie",
"Search new members (name or email)": "Rechercher de nouveaux membres (nom ou email)",
@@ -147,12 +177,15 @@
"Sort the domain names by creation date descendent": "Trier les documents par date de création descendante",
"Sort the teams by creation date ascendent": "Trier les groupes par date de création ascendante",
"Sort the teams by creation date descendent": "Trier les groupes par date de création descendante",
"Status": "Statut",
"Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Directrice numérique interministériel (DINUM).",
"Support email address": "Adresse e-mail du support",
"Team icon": "Icône équipe",
"Team name": "Nom du groupe",
"Teams": "Équipes",
"The National Agency for Territorial Cohesion undertakes to make its\n service accessible, in accordance with article 47 of law no. 2005-102\n of February 11, 2005.": "L'Agence Nationale de la Cohésion des Territoires sengage à rendre son service accessible, conformément à larticle 47 de la loi n° 2005-102 du 11 février 2005.",
"The access has been removed from the domain": "L'accès a été supprimé du domaine",
"The domain is currently in action required status. Please take the necessary actions to resolve those following issues.": "Le domaine est actuellement en statut actions requises. Veuillez faire le nécessaire pour résoudre les problèmes suivants.",
"The domain name encounters an error. Please contact our support team to solve the problem:": "Le nom de domaine rencontre une erreur. Veuillez contacter notre support pour résoudre le problème :",
"The mail domain secret is misconfigured. Please, contact our support team to solve the issue: suiteterritoriale@anct.gouv.fr": "Le secret du domaine de messagerie est mal configuré. Veuillez contacter notre support pour résoudre le problème : suiteterritoriale@anct.gouv.fr",
"The member has been removed from the team": "Le membre a été supprimé de votre groupe",
@@ -188,15 +221,17 @@
"You cannot remove other owner.": "Vous ne pouvez pas supprimer un autre propriétaire.",
"You cannot update the role of other owner.": "Vous ne pouvez pas mettre à jour les rôles d'autre propriétaire.",
"You must have minimum 1 character": "Vous devez entrer au moins 1 caractère",
"Your domain name is being validated. You will not be able to create mailboxes until your domain name has been validated by our team.": "Votre nom de domaine est en cours de validation. Vous ne pourrez créer de boîtes mail que lorsque votre nom de domaine sera validé par notre équipe.",
"Your request cannot be processed because the server is experiencing an error. If the problem persists, please contact our support to resolve the issue: suiteterritoriale@anct.gouv.fr": "Votre demande ne peut pas être traitée car le serveur rencontre une erreur. Si le problème persiste, veuillez contacter notre support pour résoudre le problème : suiteterritoriale@anct.gouv.fr",
"[action required]": "[action requise]",
"[disabled]": "[désactivé]",
"[enabled]": "[actif]",
"[failed]": "[erroné]",
"[failed]": "[en erreur]",
"[pending]": "[en attente]",
"accessibility-contact-defenseurdesdroits": "Contacter le délégué du<1>Défenseur des droits dans votre région</1>",
"accessibility-form-defenseurdesdroits": "Écrire un message au<1>Défenseur des droits</1>",
"copy done": "copié",
"mail domains list loading": "chargement de la liste des domaines de messagerie",
"with value:": "avec la valeur:",
"{{count}} member_many": "{{count}} membres",
"{{count}} member_one": "{{count}} membre",
"{{count}} member_other": "{{count}} membres",
@@ -1,11 +1,12 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React, { ReactElement } from 'react';
import React, { ReactElement, useState } from 'react';
import { Box } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import {
MailDomain,
MailDomainsLayout,
useMailDomain,
} from '@/features/mail-domains/domains';
@@ -14,13 +15,15 @@ import { NextPageWithLayout } from '@/types/next';
const MailboxesPage: NextPageWithLayout = () => {
const router = useRouter();
const [currentMailDomain, setCurrentMailDomain] = useState<MailDomain | null>(
null,
);
if (router?.query?.slug && typeof router.query.slug !== 'string') {
throw new Error('Invalid mail domain slug');
}
const { slug } = router.query;
const navigate = useNavigate();
const {
@@ -30,6 +33,13 @@ const MailboxesPage: NextPageWithLayout = () => {
isLoading,
} = useMailDomain({ slug: String(slug) });
// Update currentMailDomain when mailDomain changes
React.useEffect(() => {
if (mailDomain) {
setCurrentMailDomain(mailDomain);
}
}, [mailDomain]);
if (error?.status === 404) {
navigate.replace(`/404`);
return null;
@@ -47,11 +57,16 @@ const MailboxesPage: NextPageWithLayout = () => {
);
}
if (!mailDomain) {
if (!currentMailDomain) {
return null;
}
return <MailDomainView mailDomain={mailDomain} />;
return (
<MailDomainView
mailDomain={currentMailDomain}
onMailDomainUpdate={setCurrentMailDomain}
/>
);
};
MailboxesPage.getLayout = function getLayout(page: ReactElement) {
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName, 'marie');
});
test.describe('When a commune, domain is created on first login via ProConnect', () => {
test('it checks the domain has been created and is operational', async ({
page,
}) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Marianne Logo')).toBeVisible();
await page
.locator('menu')
.first()
.getByLabel(`Mail Domains button`)
.click();
await expect(page).toHaveURL(/mail-domains\//);
await expect(
page.getByLabel('Mail domains panel', { exact: true }),
).toBeVisible();
await expect(page.getByText('merlaut.collectivite.fr')).toHaveCount(1);
await expect(page.getByText('No domains exist.')).toHaveCount(0);
});
});
@@ -9,6 +9,7 @@ const getElements = (page: Page) => {
});
const form = page.locator('form');
const inputName = form.getByLabel('Domain name');
const inputSupportEmail = form.getByLabel('Support email address');
const buttonSubmit = page.getByRole('button', {
name: 'Add the domain',
});
@@ -22,6 +23,7 @@ const getElements = (page: Page) => {
linkIndexPageAddDomain,
form,
inputName,
inputSupportEmail,
buttonCancel,
buttonSubmit,
};
@@ -50,6 +52,7 @@ test.describe('Add Mail Domains', () => {
}),
).toBeVisible();
await expect(inputName).toBeVisible();
await expect(inputName).toBeVisible();
await expect(page.getByText('Example: saint-laurent.fr')).toBeVisible();
@@ -82,12 +85,17 @@ test.describe('Add Mail Domains', () => {
test('checks form invalid status', async ({ page }) => {
await page.goto('/mail-domains/');
const { linkIndexPageAddDomain, inputName, buttonSubmit } =
getElements(page);
const {
linkIndexPageAddDomain,
inputName,
inputSupportEmail,
buttonSubmit,
} = getElements(page);
await linkIndexPageAddDomain.click();
await expect(inputName).toBeVisible();
await expect(inputSupportEmail).toBeVisible();
await expect(page.getByText('Example: saint-laurent.fr')).toBeVisible();
await expect(
@@ -111,16 +119,23 @@ test.describe('Add Mail Domains', () => {
browserName,
}) => {
const mailDomainName = randomName('versailles.fr', browserName, 1)[0];
const mailDomainSupportMail = 'support@'.concat(mailDomainName);
const mailDomainSlug = mailDomainName.replace('.', '');
await page.goto('/mail-domains/');
const { linkIndexPageAddDomain, inputName, buttonSubmit } =
getElements(page);
const {
linkIndexPageAddDomain,
inputName,
inputSupportEmail,
buttonSubmit,
} = getElements(page);
await linkIndexPageAddDomain.click();
await inputName.fill(mailDomainName);
await inputSupportEmail.fill(mailDomainSupportMail);
await buttonSubmit.click();
await expect(page).toHaveURL(`/mail-domains/${mailDomainSlug}/`);
@@ -14,7 +14,7 @@ test.describe('OIDC interop with SIRET', () => {
);
expect(response.ok()).toBeTruthy();
expect(await response.json()).toMatchObject({
organization: { registration_id_list: ['21580304000017'] },
organization: { registration_id_list: ['21510339100011'] },
});
});
});
@@ -28,6 +28,6 @@ test.describe('When a commune, display commune name below user name', () => {
name: 'Marie Delamairie',
});
await expect(logout.getByText('Varzy')).toBeVisible();
await expect(logout.getByText('Merlaut')).toBeVisible();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.10.1",
"version": "1.12.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+1 -1
View File
@@ -44,7 +44,7 @@ export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], locale: 'en-US' },
use: { ...devices['Desktop Chrome'], locale: 'en-US', channel: 'chrome' },
},
],
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "people",
"version": "1.10.1",
"version": "1.12.0",
"private": true,
"workspaces": {
"packages": [
@@ -24,7 +24,7 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@types/node": "22.13.0",
"@types/node": "22.13.1",
"@types/react-dom": "19.0.3",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -1,17 +1,17 @@
{
"name": "eslint-config-people",
"version": "1.10.1",
"version": "1.12.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
},
"dependencies": {
"@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",
"@next/eslint-plugin-next": "15.1.7",
"@tanstack/eslint-plugin-query": "5.66.1",
"@typescript-eslint/eslint-plugin": "8.24.0",
"@typescript-eslint/parser": "8.24.0",
"eslint": "8.57.0",
"eslint-config-next": "15.1.6",
"eslint-config-next": "15.1.7",
"eslint-config-prettier": "10.0.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.11.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.10.1",
"version": "1.12.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:desk",
+148 -143
View File
@@ -1413,10 +1413,10 @@
use-deep-compare-effect "^1.8.1"
uuid "^8.3.2"
"@hookform/resolvers@3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.10.0.tgz#7bfd18113daca4e57e27e1205b7d5a2d371aa59a"
integrity sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==
"@hookform/resolvers@4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-4.0.0.tgz#730cb3a32bc236059264ec9fb7563f4eb3f4fb9d"
integrity sha512-93ZueVlTaeMF0pRbrLbcnzrxeb2mGA/xyO3RgfrsKs5OCtcfjoWcdjBJm+N7096Jfg/JYlGPjuyQCgqVEodSTg==
"@humanwhocodes/config-array@^0.11.14":
version "0.11.14"
@@ -1834,57 +1834,57 @@
dependencies:
buffer "^6.0.3"
"@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/env@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.7.tgz#14e2678f893aec50ff2dcb7a6665092fb9e1263d"
integrity sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ==
"@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==
"@next/eslint-plugin-next@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.7.tgz#f8593c714f51ac6d443fb298584954d5def4392b"
integrity sha512-kRP7RjSxfTO13NE317ek3mSGzoZlI33nc/i5hs1KaWpK+egs85xg0DJ4p32QEiHnR0mVjuUfhRIun7awqfL7pQ==
dependencies:
fast-glob "3.3.1"
"@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-arm64@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.7.tgz#ecc6eacf174df36a6c73f7c319ed864ec6e08079"
integrity sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ==
"@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-darwin-x64@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.7.tgz#d25b4c131d13439ea4b263dbcd0fd518a835f31c"
integrity sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ==
"@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-gnu@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.7.tgz#b19abc7b56918042b5309f55f7010e7932ee4967"
integrity sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA==
"@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-arm64-musl@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.7.tgz#cb2ac35d3024e9d46ce0d4ff03bf491e0773519f"
integrity sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w==
"@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-gnu@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.7.tgz#cf6e338a1fbb1c9b019c158a76a7ab4f143929ce"
integrity sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ==
"@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-linux-x64-musl@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.7.tgz#94c9117ece8e5851e7e6674f12d6b82b435e3a6f"
integrity sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ==
"@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-arm64-msvc@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.7.tgz#4947f3b7f41c7347114985bf3c91e2eacddfe124"
integrity sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q==
"@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==
"@next/swc-win32-x64-msvc@15.1.7":
version "15.1.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.7.tgz#0adb399deb15291b61be94909a97e0d6ce1f61fa"
integrity sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -3347,10 +3347,10 @@
dependencies:
tslib "^2.4.0"
"@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==
"@tanstack/eslint-plugin-query@5.66.1":
version "5.66.1"
resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.66.1.tgz#9951e4d3633ca6248196742cad437b4d7c760cd7"
integrity sha512-pYMVTGgJ7yPk9Rm6UWEmbY6TX0EmMmxJqYkthgeDCwEznToy2m+W928nUODFirtZBZlhBsqHy33LO0kyTlgf0w==
dependencies:
"@typescript-eslint/utils" "^8.18.1"
@@ -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.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==
"@types/node@*", "@types/node@22.13.1":
version "22.13.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.1.tgz#a2a3fefbdeb7ba6b89f40371842162fac0934f33"
integrity sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==
dependencies:
undici-types "~6.20.0"
@@ -3648,20 +3648,20 @@
dependencies:
"@types/yargs-parser" "*"
"@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==
"@typescript-eslint/eslint-plugin@8.24.0":
version "8.24.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz#574a95d67660a1e4544ae131d672867a5b40abb3"
integrity sha512-aFcXEJJCI4gUdXgoo/j9udUYIHgF23MFkg09LFz2dzEmU0+1Plk4rQWv/IYKvPHAtlkkGoB3m5e6oUp+JPsNaQ==
dependencies:
"@eslint-community/regexpp" "^4.10.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"
"@typescript-eslint/scope-manager" "8.24.0"
"@typescript-eslint/type-utils" "8.24.0"
"@typescript-eslint/utils" "8.24.0"
"@typescript-eslint/visitor-keys" "8.24.0"
graphemer "^1.4.0"
ignore "^5.3.1"
natural-compare "^1.4.0"
ts-api-utils "^2.0.0"
ts-api-utils "^2.0.1"
"@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.13.0"
@@ -3678,15 +3678,15 @@
natural-compare "^1.4.0"
ts-api-utils "^1.3.0"
"@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==
"@typescript-eslint/parser@8.24.0":
version "8.24.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.24.0.tgz#bba837f9ee125b78f459ad947ff9b61be8139085"
integrity sha512-MFDaO9CYiard9j9VepMNa9MTcqVvSny2N4hkY6roquzj8pdCBRENhErrteaQuu7Yjn1ppk0v1/ZF9CG3KIlrTA==
dependencies:
"@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"
"@typescript-eslint/scope-manager" "8.24.0"
"@typescript-eslint/types" "8.24.0"
"@typescript-eslint/typescript-estree" "8.24.0"
"@typescript-eslint/visitor-keys" "8.24.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.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==
"@typescript-eslint/scope-manager@8.24.0":
version "8.24.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.24.0.tgz#2e34b3eb2ce768f2ffb109474174ced5417002b1"
integrity sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==
dependencies:
"@typescript-eslint/types" "8.22.0"
"@typescript-eslint/visitor-keys" "8.22.0"
"@typescript-eslint/types" "8.24.0"
"@typescript-eslint/visitor-keys" "8.24.0"
"@typescript-eslint/type-utils@8.13.0":
version "8.13.0"
@@ -3742,15 +3742,15 @@
debug "^4.3.4"
ts-api-utils "^1.3.0"
"@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==
"@typescript-eslint/type-utils@8.24.0":
version "8.24.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.24.0.tgz#6ee3ec4db06f9e5e7b01ca6c2b5dd5843a9fd1e8"
integrity sha512-8fitJudrnY8aq0F1wMiPM1UUgiXQRJ5i8tFjq9kGfRajU+dbPyOuHbl0qRopLEidy0MwqgTHDt6CnSeXanNIwA==
dependencies:
"@typescript-eslint/typescript-estree" "8.22.0"
"@typescript-eslint/utils" "8.22.0"
"@typescript-eslint/typescript-estree" "8.24.0"
"@typescript-eslint/utils" "8.24.0"
debug "^4.3.4"
ts-api-utils "^2.0.0"
ts-api-utils "^2.0.1"
"@typescript-eslint/types@8.13.0":
version "8.13.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.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/types@8.24.0":
version "8.24.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.24.0.tgz#694e7fb18d70506c317b816de9521300b0f72c8e"
integrity sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==
"@typescript-eslint/typescript-estree@8.13.0":
version "8.13.0"
@@ -3814,19 +3814,19 @@
semver "^7.6.0"
ts-api-utils "^2.0.0"
"@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==
"@typescript-eslint/typescript-estree@8.24.0":
version "8.24.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.0.tgz#0487349be174097bb329a58273100a9629e03c6c"
integrity sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==
dependencies:
"@typescript-eslint/types" "8.22.0"
"@typescript-eslint/visitor-keys" "8.22.0"
"@typescript-eslint/types" "8.24.0"
"@typescript-eslint/visitor-keys" "8.24.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
minimatch "^9.0.4"
semver "^7.6.0"
ts-api-utils "^2.0.0"
ts-api-utils "^2.0.1"
"@typescript-eslint/utils@8.13.0", "@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.13.0"
@@ -3838,15 +3838,15 @@
"@typescript-eslint/types" "8.13.0"
"@typescript-eslint/typescript-estree" "8.13.0"
"@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==
"@typescript-eslint/utils@8.24.0":
version "8.24.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.24.0.tgz#21cb1195ae79230af825bfeed59574f5cb70a749"
integrity sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@typescript-eslint/scope-manager" "8.22.0"
"@typescript-eslint/types" "8.22.0"
"@typescript-eslint/typescript-estree" "8.22.0"
"@typescript-eslint/scope-manager" "8.24.0"
"@typescript-eslint/types" "8.24.0"
"@typescript-eslint/typescript-estree" "8.24.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.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==
"@typescript-eslint/visitor-keys@8.24.0":
version "8.24.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.0.tgz#36ecf0b9b1d819ad88a3bd4157ab7d594cb797c9"
integrity sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==
dependencies:
"@typescript-eslint/types" "8.22.0"
"@typescript-eslint/types" "8.24.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.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==
eslint-config-next@15.1.7:
version "15.1.7"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.7.tgz#80d287e6d1c0742304de8fe57cbc45000c455c11"
integrity sha512-zXoMnYUIy3XHaAoOhrcYkT9UQWvXqWju2K7NNsmb5wd/7XESDwof61eUdW4QhERr3eJ9Ko/vnXqIrj8kk/drYw==
dependencies:
"@next/eslint-plugin-next" "15.1.6"
"@next/eslint-plugin-next" "15.1.7"
"@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"
@@ -7758,12 +7758,12 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
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==
next@15.1.7:
version "15.1.7"
resolved "https://registry.yarnpkg.com/next/-/next-15.1.7.tgz#e814845e7cdb0294aee88ceab0bb962de83e8e6f"
integrity sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg==
dependencies:
"@next/env" "15.1.6"
"@next/env" "15.1.7"
"@swc/counter" "0.1.3"
"@swc/helpers" "0.5.15"
busboy "1.6.0"
@@ -7771,14 +7771,14 @@ next@15.1.6:
postcss "8.4.31"
styled-jsx "5.1.6"
optionalDependencies:
"@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"
"@next/swc-darwin-arm64" "15.1.7"
"@next/swc-darwin-x64" "15.1.7"
"@next/swc-linux-arm64-gnu" "15.1.7"
"@next/swc-linux-arm64-musl" "15.1.7"
"@next/swc-linux-x64-gnu" "15.1.7"
"@next/swc-linux-x64-musl" "15.1.7"
"@next/swc-win32-arm64-msvc" "15.1.7"
"@next/swc-win32-x64-msvc" "15.1.7"
sharp "^0.33.5"
no-case@^3.0.4:
@@ -8146,14 +8146,14 @@ postcss@8.4.31:
picocolors "^1.0.0"
source-map-js "^1.0.2"
postcss@8.4.38:
version "8.4.38"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==
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==
dependencies:
nanoid "^3.3.7"
picocolors "^1.0.0"
source-map-js "^1.2.0"
picocolors "^1.1.1"
source-map-js "^1.2.1"
postcss@^8.5.1:
version "8.5.1"
@@ -8176,10 +8176,10 @@ prettier-linter-helpers@^1.0.0:
dependencies:
fast-diff "^1.1.2"
prettier@3.4.2:
version "3.4.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.2.tgz#a5ce1fb522a588bf2b78ca44c6e6fe5aa5a2b13f"
integrity sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==
prettier@3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.0.tgz#50325a28887c6dfdf2ca3f8eaba02b66a8429ca7"
integrity sha512-quyMrVt6svPS7CjQ9gKb3GLEX/rl3BCL2oa/QkNcXv4YNVBC9olt3s+H7ukto06q7B1Qz46PbrKLO34PR6vXcA==
pretty-format@^27.0.2:
version "27.5.1"
@@ -8768,10 +8768,10 @@ safe-regex-test@^1.1.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sass@1.83.4:
version "1.83.4"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.83.4.tgz#5ccf60f43eb61eeec300b780b8dcb85f16eec6d1"
integrity sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA==
sass@1.84.0:
version "1.84.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.84.0.tgz#da9154cbccb2d2eac7a9486091b6d9ba93ef5bad"
integrity sha512-XDAbhEPJRxi7H0SxrnOpiXFQoUJHwkR2u3Zc4el+fK/Tt5Hpzw5kkQ59qVDfvdaUq6gCrEZIbySFBM2T9DNKHg==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"
@@ -8979,7 +8979,7 @@ sort-keys@^5.0.0:
dependencies:
is-plain-obj "^4.0.0"
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.0, source-map-js@^1.2.1:
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
@@ -9194,17 +9194,17 @@ strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
styled-components@6.1.14:
version "6.1.14"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.1.14.tgz#addd3b645f400f8aad84c992afec58804ad1d217"
integrity sha512-KtfwhU5jw7UoxdM0g6XU9VZQFV4do+KrM8idiVCH5h4v49W+3p3yMe0icYwJgZQZepa5DbH04Qv8P0/RdcLcgg==
styled-components@6.1.15:
version "6.1.15"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.1.15.tgz#7651904d5424d08c1046056eb39024cc23c72ab7"
integrity sha512-PpOTEztW87Ua2xbmLa7yssjNyUF9vE7wdldRfn1I2E6RTkqknkBYpj771OxM/xrvRGinLy2oysa7GOd7NcZZIA==
dependencies:
"@emotion/is-prop-valid" "1.2.2"
"@emotion/unitless" "0.8.1"
"@types/stylis" "4.2.5"
css-to-react-native "3.2.0"
csstype "3.1.3"
postcss "8.4.38"
postcss "8.4.49"
shallowequal "1.1.0"
stylis "4.3.2"
tslib "2.6.2"
@@ -9465,6 +9465,11 @@ ts-api-utils@^2.0.0:
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.0.0.tgz#b9d7d5f7ec9f736f4d0f09758b8607979044a900"
integrity sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==
ts-api-utils@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.0.1.tgz#660729385b625b939aaa58054f45c058f33f10cd"
integrity sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==
ts-jest@29.2.5:
version "29.2.5"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.2.5.tgz#591a3c108e1f5ebd013d3152142cb5472b399d63"
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: desk
version: 0.0.1
version: 0.0.2
+2
View File
@@ -37,6 +37,7 @@
| Name | Description | Value |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `backend.pdb.enabled` | Enable pdb on backend | `true` |
| `backend.dpAnnotations` | Annotations to add to the backend Deployment | `{}` |
| `backend.command` | Override the backend container command | `[]` |
| `backend.args` | Override the backend container args | `[]` |
@@ -86,6 +87,7 @@
| Name | Description | Value |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------- | ------------------------- |
| `frontend.pdb.enabled` | Enable pdb on backend | `true` |
| `frontend.image.repository` | Repository to use to pull desk's frontend container image | `lasuite/people-frontend` |
| `frontend.image.tag` | desk's frontend container tag | `latest` |
| `frontend.image.pullPolicy` | frontend container image pull policy | `IfNotPresent` |
@@ -138,3 +138,16 @@ spec:
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.backend.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "desk.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
@@ -138,3 +138,16 @@ spec:
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.frontend.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "desk.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
+8
View File
@@ -72,6 +72,10 @@ ingressAdmin:
## @section backend
backend:
## @param backend.pdb.enabled Enable pdb on backend
pdb:
enabled: true
## @param backend.dpAnnotations Annotations to add to the backend Deployment
dpAnnotations: {}
@@ -179,6 +183,10 @@ backend:
## @section frontend
frontend:
## @param frontend.pdb.enabled Enable pdb on backend
pdb:
enabled: true
## @param frontend.image.repository Repository to use to pull desk's frontend container image
## @param frontend.image.tag desk's frontend container tag
## @param frontend.image.pullPolicy frontend container image pull policy
@@ -14,7 +14,7 @@ backend:
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_SUPERUSER_EMAIL: admin@example.com
DJANGO_EMAIL_HOST_PASSWORD: changeme
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_HOST: "maildev"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
OIDC_RS_CLIENT_ID: changeme
@@ -32,7 +32,6 @@ backend:
OIDC_RP_SCOPES: "openid email siret"
OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
ORGANIZATION_PLUGINS: "plugins.organizations.NameFromSiretOrganizationPlugin"
ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "^[0-9]{14}$"}}]'
LOGIN_REDIRECT_URL: https://desk.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io
@@ -101,12 +100,3 @@ 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
+3 -12
View File
@@ -29,7 +29,7 @@ backend:
DJANGO_SETTINGS_MODULE: people.settings
DJANGO_SUPERUSER_EMAIL: admin@example.com
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_HOST: "maildev"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks
@@ -47,10 +47,10 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email siret"
OIDC_RP_SCOPES: "openid email siret given_name usual_name"
USER_OIDC_FIELDS_TO_NAME: "given_name,usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
ORGANIZATION_PLUGINS: "plugins.organizations.NameFromSiretOrganizationPlugin"
ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "^[0-9]{14}$"}}]'
LOGIN_REDIRECT_URL: https://desk.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io
@@ -107,12 +107,3 @@ 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
@@ -0,0 +1,3 @@
ingress:
enabled: true
host: maildev.127.0.0.1.nip.io
+3 -3
View File
@@ -100,10 +100,10 @@ releases:
values:
- env.d/{{ .Environment.Name }}/values.desk.yaml.gotmpl
- name: mailcatcher
- name: maildev
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: ./mailcatcher
chart: ./maildev
values:
- env.d/{{ .Environment.Name }}/values.desk.yaml.gotmpl
- env.d/values.maildev.yaml.gotmpl
-5
View File
@@ -1,5 +0,0 @@
apiVersion: v2
name: mailcatcher
description: A simple SMTP server with a web interface for testing email in development
version: 1.0.0
type: application
@@ -1,25 +0,0 @@
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
@@ -1,20 +0,0 @@
{{- 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 }}
@@ -1,19 +0,0 @@
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
@@ -1,16 +0,0 @@
image:
repository: sj26/mailcatcher
tag: latest
pullPolicy: IfNotPresent
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
ingress:
enabled: false
host: ""
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v2
name: maildev
description: SMTP Server + Web Interface for viewing and testing emails during development.
version: 1.0.0
type: application
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: maildev
labels:
app: maildev
spec:
replicas: 1
selector:
matchLabels:
app: maildev
template:
metadata:
labels:
app: maildev
spec:
containers:
- name: maildev
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: smtp-port
containerPort: {{ .Values.ports.smtp }}
protocol: TCP
- name: web-port
containerPort: {{ .Values.ports.web }}
protocol: TCP
env:
{{- if or .Values.web.user .Values.web.pass}}
- name: MAILDEV_WEB_USER
value: "{{ .Values.web.user }}"
- name: MAILDEV_WEB_PASS
value: "{{ .Values.web.pass }}"
{{- end }}
- name: MAILDEV_SMTP_PORT
value: "{{ .Values.ports.smtp }}"
- name: MAILDEV_WEB_PORT
value: "{{ .Values.ports.web }}"
- name: MAILDEV_HTTPS
value: "{{ .Values.https.enabled }}"
{{- if .Values.https.key }}
- name: MAILDEV_HTTPS_KEY
value: "{{ .Values.https.key }}"
{{- end }}
{{- if .Values.https.cert }}
- name: MAILDEV_HTTPS_CERT
value: "{{ .Values.https.cert }}"
{{- end }}
{{- if .Values.incoming }}
- name: MAILDEV_INCOMING_USER
value: "{{ .Values.incoming.user }}"
- name: MAILDEV_INCOMING_PASS
value: "{{ .Values.incoming.pass }}"
{{- end }}
livenessProbe:
httpGet:
path: /healthz
port: {{ .Values.ports.web }}
readinessProbe:
httpGet:
path: /healthz
port: {{ .Values.ports.web }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
+20
View File
@@ -0,0 +1,20 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: maildev
labels:
app: maildev
spec:
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: {{ .Values.ingress.path }}
pathType: Prefix
backend:
service:
name: maildev-web
port:
number: {{ .Values.ports.web }}
{{- end }}
@@ -0,0 +1,17 @@
{{- if .Values.ports.smtp }}
apiVersion: v1
kind: Service
metadata:
name: maildev-smtp
labels:
app: maildev
spec:
type: ClusterIP
ports:
- name: smtp-{{ .Values.ports.smtp }}-tcp
port: {{ .Values.ports.smtp }}
protocol: TCP
targetPort: {{ .Values.ports.smtp }}
selector:
app: maildev
{{- end }}
@@ -0,0 +1,17 @@
{{- if .Values.ports.web }}
apiVersion: v1
kind: Service
metadata:
name: maildev-web
labels:
app: maildev
spec:
type: ClusterIP
ports:
- name: web-{{ .Values.ports.web }}-tcp
port: {{ .Values.ports.web }}
protocol: TCP
targetPort: {{ .Values.ports.web }}
selector:
app: maildev
{{- end }}
+40
View File
@@ -0,0 +1,40 @@
image:
repository: maildev/maildev
tag: "2.2.1"
pullPolicy: IfNotPresent
ports:
# MAILDEV_SMTP_PORT
smtp: 1025
# MAILDEV_WEB_PORT
web: 1080
# MAILDEV_HTTPS
https:
enabled:
# MAILDEV_HTTPS_KEY
# key:
# MAILDEV_HTTPS_KEY
# cert:
# Web interface
web:
# MAILDEV_DISABLE_WEB
disable: false
# MAILDEV_WEB_USER
user:
# MAILDEV_WEB_PASS
pass:
incoming:
# MAILDEV_INCOMING_USER
# user:
# MAILDEV_INCOMING_PASS
# pass:
ingress:
enabled: false
host: ""
path: /
resources:
+1 -1
View File
@@ -16,7 +16,7 @@
"volta": {
"node": "16.15.1"
},
"repository": "https://github.com/numerique-gouv/people",
"repository": "https://github.com/suitenumerique/people",
"author": "DINUM",
"license": "MIT"
}
+3 -3
View File
@@ -8,7 +8,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/numerique-gouv/people.git"
"url": "git+https://github.com/suitenumerique/people.git"
},
"author": {
"name": "DINUM",
@@ -16,9 +16,9 @@
},
"license": "MIT",
"bugs": {
"url": "https://github.com/numerique-gouv/people/issues"
"url": "https://github.com/suitenumerique/people/issues"
},
"homepage": "https://github.com/numerique-gouv/people#readme",
"homepage": "https://github.com/suitenumerique/people#readme",
"devDependencies": {
"openapi-typescript-codegen": "0.29.0"
}