Compare commits

...

57 Commits

Author SHA1 Message Date
Marie PUPO JEAMMET de46a50e8d 🔖(minor) release version 1.2.0
Update all version files and changelog for minor release.
2024-09-30 17:38:44 +02:00
Sabrina Demagny e6ed3c3be2 (backend) domain accesses delete API
Allow to delete a access for a domain.
2024-09-30 17:09:27 +02:00
daproclaima f04c8bc6aa 🥅(frontend) catch new errors on mailbox creation
- catch errors related to MAIL_PROVISIONING_API_CREDENTIALS
introduced in commit #ba30b1d3eec73718add6585f30c6b7959cb21850.
Intentionally does not parse the error
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
as it means the user is neither admin or owner of the domain and
should not access the mailbox creation form
- update translations and component tests
2024-09-30 15:31:26 +01:00
Marie PUPO JEAMMET ae05b430db 🚨(pylint) fix linting error introduced by new pylint version
pylint version v3.3.1 added a new error and broke our CI
2024-09-30 13:06:39 +02:00
Marie PUPO JEAMMET c98d173c0a ✏️(makefile) fix typo in makefile, for dimail setup
make boostrap command failed because of wrong naming of
dimail setup command
2024-09-30 13:06:39 +02:00
renovate[bot] dddc281778 ⬆️(dependencies) update python dependencies 2024-09-30 13:06:39 +02:00
Jacques ROUSSEL bb2279b6d5 🐛(argocd) fix job synchronisation issue
Sometime argocd is locked because of immutable fiels on jobs. The
annotation tells argocd to do a replace to avoid this behaviour
2024-09-30 12:51:36 +02:00
Jacques ROUSSEL 5420b20f43 (ci) add helmfile linter
Add a linter to avoid to merge config that breaks argocd sync
2024-09-30 12:51:36 +02:00
Jacques ROUSSEL fbb2accefb 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-09-30 11:27:33 +02:00
renovate[bot] 75008d3e9a ⬆️(dependencies) update js dependencies 2024-09-30 11:11:13 +02:00
Sabrina Demagny c4c3e9de96 (backend) domain accesses create API
Allow to create (POST) a new access for a domain.
Role can be change only to a role available and
depending to the authenticated user.
2024-09-27 17:55:15 +02:00
Sabrina Demagny 00816e097c (backend) domain accesses update API
Allow to update (PUT, PATCH) an access.
Role can be change only to a role available
depending to the authenticated user.
2024-09-27 16:09:15 +02:00
daproclaima 976c955126 ️(frontend) update left nav panel
- the HTML semantic needed improvement:
the menu items were button wrapped in a li
wrapped in link.
- update related tests
2024-09-27 15:29:18 +02:00
Marie PUPO JEAMMET 2967f1bff2 🐛(dimail) fix mail notification upon mailbox creation
Link to the webmail was missing from notification email.
This fixes html for link to correctly appear.
2024-09-27 12:20:56 +02:00
Marie PUPO JEAMMET a15d548ac4 🐛(dimail) remove trailing slash to lighten dimail's calls/logs
Remove trailing slash on mailbox creation request
to remove 307 redirects
2024-09-26 20:53:25 +02:00
Marie PUPO JEAMMET 7c6b3307fa (dimail) fix logger response in unauthorized response
a test was broken because of logger not logging errors
2024-09-26 20:53:25 +02:00
Marie PUPO JEAMMET 5ded297df6 (mailbox) send new mailbox confirmation email
send mailbox information upon creating a new mailbox
2024-09-26 20:53:25 +02:00
daproclaima b69ce001c8 (frontend) allow group members filtering
- add title above the list of members
- allow members filtering  with an input
- updates translations and related tests
- add global useDebounce hook to prevent
spamming API with useless requests on
inputs sending requests on value change
- add component and e2e tests
2024-09-26 18:09:31 +02:00
daproclaima c0cd136618 ♻️(frontend) improve CardCreateTeam component
- remove link wrapping cancel button and
tabIndex=-1 to button
2024-09-26 17:20:58 +02:00
daproclaima ee5a785d43 🥅(frontend) improve add member form error handling
- translate known errors, including already existing
group error, and directly display the other ones
- add component tests
- update translations
- add parseAPIError, a reusable function to
catch errors on the whole frontend app

Closes issue #293

♻️(frontend) improve general error catching

- change parseAPIError to make it reusable on all
requests
- update components depending on it
2024-09-26 17:20:58 +02:00
daproclaima 3d7dfa019c ⚗️(frontend) show username on AccountDropDown
- show username instead of "My account"
- udpate translations and tests
2024-09-26 16:32:18 +02:00
renovate[bot] cc9303bc9c ⬆️(dependencies) update js dependencies 2024-09-24 20:37:13 +02:00
Jacques ROUSSEL 34341e6f74 🔒️(helm) fix secret sync precedence
When new secret is added to backend secret, it's not sync at the
beginning of argocd synchronisation and jobs are blocked. Theses new
annotations fix this issue.
2024-09-23 12:52:04 +02:00
Marie PUPO JEAMMET 01abc66e59 (dimail) allow la regie to request a token for another user
allow la regie to request a token for another dimail user,
to better track who created/modified which ressource.
2024-09-20 17:21:31 +02:00
Marie PUPO JEAMMET 55d7e846d8 ♻️(serializers) move dimail calls to serializers
we move all business logic from model to serializer.
all API calls (direct and from front) will keep on triggering
expected 3rd party calls while admin actions will uniquely trigger
modifications in our database.
2024-09-20 14:20:22 +02:00
Marie PUPO JEAMMET 232ea97d87 (dimail) populate dimail local database for dev use
this commit adds a script and 'make' command to populate dimail database
with basic objects: an admin account, a regie account, a domain
and an owner for this domain.
2024-09-19 15:10:35 +02:00
renovate[bot] a7afa68afa ⬆️(dependencies) update next to v14.2.10 [SECURITY] 2024-09-19 10:06:09 +02:00
renovate[bot] 932ffb3bcb ⬆️(dependencies) update js dependencies 2024-09-19 09:54:40 +02:00
Jacques ROUSSEL d0254ce963 🔧(helm) bump secret
Bump secret to use the email provisionning secret
2024-09-18 19:02:34 +02:00
renovate[bot] cd1dcf11d5 ⬆️(dependencies) update python dependencies 2024-09-17 19:01:23 +02:00
Marie PUPO JEAMMET 59468aaa12 🍱(dimail) embark dimail-api as container
Embark a dimail-api container, automatically fetched from
their repository, to ensure our "bridge" to dimail-api
is up-to-date when developing.
2024-09-17 18:34:39 +02:00
Sabrina Demagny dd8bd2a89b (backend) domain accesses list API
Add an endpoint to list all accesses created for a domain
Return all roles available to set for each access depending to
the authenticated user.
2024-09-17 17:13:46 +02:00
rouja cc86a3bd61 🔧(helm) add marie key to dev sops secret (#394)
Marie's key was accidentally removed in last commit.
Add her key and restore .sops.yaml file, to easily add keys
for local dev secrets decryption/encryption.
2024-09-11 16:59:22 +02:00
Marie PUPO JEAMMET 7f31a2b820 🔖(minor) release version 1.1.0
Update all version files and changelog for minor release.
2024-09-10 17:20:05 +02:00
Jacques ROUSSEL aaca8819b3 🔧(helm) fix mail provisioning env
I forgot to use the new variable on the deployment.
2024-09-10 16:50:14 +02:00
Sabrina Demagny 77cc64a6c7 (release) add python script to prepare release
Change versions, update changelog and create branch and commit
to submit release informations.
Give following instructions to do after.
2024-09-10 15:30:30 +02:00
Marie PUPO JEAMMET aaad48480a (dimail) fix tests still waiting for domain.secret
For unknown reasons, these tests were forgotten and are still
refering to this 'secret' field, removed in last commit.
2024-09-10 15:29:19 +02:00
Marie PUPO JEAMMET 00dafd4b15 ♻️(dimail) separate headers request from mailbox request
I want to separate headers request form mailbox request,
as we were previously catching the same errors twice.
It should be clearer now.
2024-09-10 15:29:19 +02:00
Jacques ROUSSEL 864702d0ee 🔧(helm) add secret for email provisionning
Add email provisionning secret to be abble to provision email
2024-09-09 18:18:00 +02:00
Marie PUPO JEAMMET 29904ef7b6 (tests) update tests to look for dimail secret in settings
Update back-end tests to match 'secret' field being moved to settings.
2024-09-09 18:18:00 +02:00
Marie PUPO JEAMMET ba30b1d3ee 🗃️(models) remove 'secret' field from mailbox model
We remove 'secret' field, as it won't be of use in interactions
between la Régie and dimail. Régie credentials will be stored and used
as project variable.
2024-09-09 18:18:00 +02:00
renovate[bot] 9503b073b6 ⬆️(dependencies) update python dependencies 2024-09-09 15:01:53 +02:00
daproclaima e4aed82ff2 🥅(frontend) improve error catching in forms
- rename CreateMailboxForm into ModalCreateMailbox,
and useCreateMailDomain into useAddMailDomain
- use useAPIError hook in ModalCreateMailbox.tsx and ModalAddMailDomain
- update translations and tests (include removal of e2e test able
to be asserted by component tests)
2024-09-09 12:43:29 +02:00
daproclaima 25898bbb64 🥅(frontend) handle api errors
- add hook to handle api errors.
- add related component tests
2024-09-09 12:43:29 +02:00
daproclaima b5d8e92d1e 🐛(frontend) fix mail domain creation form
- allow to submit form by pressing "Enter" key
- force focus on form when form is submited
but is invalid
- add error 500 handling
- update related e2e tests
2024-09-09 12:43:29 +02:00
Marie PUPO JEAMMET 237d64b4c5 ♻️(dimail) refacto to better handle 500 errors from dimail
simple refacto to catch all 500 errors, including when
asking for new token.
2024-09-09 12:24:48 +02:00
daproclaima 0d157d3f2b ️(frontend) improve keyboard navigation on modals
- create a temporary Modal component to apply it
a function tracking document update and set
modal elements we want to ignore a tabindex=-1.
- add component tests
- temporary fix. Better to apply them on
cunningham directly
2024-09-09 11:58:37 +02:00
daproclaima d291e55a9e 🐛(frontend) fix mail domain addition form
- remove link wrapping cancel button
2024-09-09 11:58:37 +02:00
daproclaima e7aebfe59e 🐛(frontend) keyboard navigation language picker
- add temporary fix to language picker to
ignore select on keyboard navigation. Needs
to be fixed directly in Cunningham Select
- update related e2e test
2024-09-09 11:58:37 +02:00
daproclaima bd6cd59df6 💬(frontend) change add mail domain text
- update translations and tests
2024-09-09 11:41:34 +02:00
daproclaima 874ce18134 💬(frontend) fix group member removal text
- update FR translation
2024-09-09 11:41:34 +02:00
Marie PUPO JEAMMET 4fe74733a5 📈(monitoring) configure sentry dsn
configure sentry dsn to monitor errors in sentry
2024-09-05 16:16:05 +02:00
Marie PUPO JEAMMET 4b47f80cab 🚨(tests) fix obsolete code warnings
- in docker compose, remove obsolete 'version' field
- in django, replace obsolete CheckConstraints 'check' field by 'condition'
2024-09-05 14:57:32 +02:00
Marie PUPO JEAMMET ba631fafb9 🐛(dimail) improve handling of dimail errors on failed mailbox creation
dimail is called twice when creating a mailbox (once for the token,
and once for the post on mailbox endpoint). we want to clarify
the status_codes and messages of each error to inform user and ease debug
2024-09-05 14:57:32 +02:00
renovate[bot] ce15e8a3ed ⬆️(dependencies) update python dependencies 2024-09-05 12:00:53 +02:00
Marie PUPO JEAMMET 55dc342a8b 🔨(demo) add domains creation to demo
Add domains creation to "make demo" rule. Update related test.
2024-09-03 16:11:23 +02:00
renovate[bot] 05c8f636dd ⬆️(dependencies) update js dependencies 2024-09-02 09:41:24 +02:00
94 changed files with 4622 additions and 1025 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "people,secrets"
+10
View File
@@ -0,0 +1,10 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
+49 -1
View File
@@ -8,6 +8,52 @@ and this project adheres to
## [Unreleased]
## [1.2.0] - 2024-09-30
### Added
- ✨(ci) add helmfile linter and fix argocd sync #424
- ✨(domains) add endpoint to list and retrieve domain accesses #404
- 🍱(dev) embark dimail-api as container #366
- ✨(dimail) allow la regie to request a token for another user #416
- ✨(frontend) show username on AccountDropDown #412
- 🥅(frontend) improve add & update group forms error handling #387
- ✨(frontend) allow group members filtering #363
- ✨(mailbox) send new mailbox confirmation email #397
- ✨(domains) domain accesses update API #423
- ✨(backend) domain accesses create API #428
- 🥅(frontend) catch new errors on mailbox creation #392
- ✨(api) domain accesses delete API #433
### Fixed
- ♿️(frontend) fix left nav panel #396
- 🔧(backend) fix configuration to avoid different ssl warning #432
### Changed
- ♻️(serializers) move business logic to serializers #414
## [1.1.0] - 2024-09-10
### Added
- 📈(monitoring) configure sentry monitoring #378
- 🥅(frontend) improve api error handling #355
### Changed
- 🗃️(models) move dimail 'secret' to settings #372
### Fixed
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
- 💬(frontend) fix group member removal text #382
- 💬(frontend) fix add mail domain text #382
- 🐛(frontend) fix keyboard navigation #379
- 🐛(frontend) fix add mail domain form submission #355
## [1.0.2] - 2024-08-30
### Added
@@ -32,7 +78,9 @@ 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.0.2...main
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.2.0...main
[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
+19
View File
@@ -88,6 +88,7 @@ bootstrap: \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
.PHONY: bootstrap
@@ -109,6 +110,7 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
@@ -129,6 +131,12 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
reset-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
@$(MAKE) setup-dimail-db
.PHONY: reset-dimail-container
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
@@ -272,6 +280,13 @@ i18n-generate-and-upload: \
crowdin-upload
.PHONY: i18n-generate-and-upload
# -- INTEROPERABILTY
# -- Dimail configuration
dimail-setup-db:
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db
.PHONY: dimail-setup-db
# -- Mail generator
@@ -343,3 +358,7 @@ start-kind: ## Create the kubernetes cluster
tilt-up: ## start tilt - k8s local development
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
release: ## helper for release and deployment
python scripts/release.py
.PHONY: release
+8 -2
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -168,3 +166,11 @@ services:
- "8080:8080"
depends_on:
- kc_postgresql
dimail:
image: registry.mim-libre.fr/dimail/dimail-api:latest
environment:
DIMAIL_MODE: FAKE
DIMAIL_JWT_SECRET: fake_jwt_secret
ports:
- "8001:8000"
+116
View File
@@ -0,0 +1,116 @@
import datetime
import os
import sys
from utils import run_command
RELEASE_KINDS = {'p': 'patch', 'm': 'minor', 'mj': 'major'}
def update_files(version):
"""Update all files needed with new release version"""
# pyproject.toml
sys.stdout.write("Update pyproject.toml...\n")
path = "src/backend/pyproject.toml"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if line.startswith("version = "):
last_version = line.split("=")[-1]
new_line = line.replace(last_version, f' "{version}"\n')
lines[index] = new_line
with open(path, 'w+') as file:
file.writelines(lines)
# helm files
sys.stdout.write("Update helm files...\n")
for env in ["preprod", "production"]:
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if line.startswith(" tag: "):
lines[index] = f' tag: "v{version}"\n'
with open(path, 'w+') as file:
file.writelines(lines)
# frontend package.json
sys.stdout.write("Update package.json...\n")
files_to_modify = []
filename = "package.json"
for root, _dir, files in os.walk("src/frontend"):
if filename in files and "node_modules" not in root and ".next" not in root:
files_to_modify.append(os.path.join(root, filename))
for path in files_to_modify:
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if line.startswith(' "version": '):
lines[index] = f' "version": "{version}",\n'
with open(path, 'w+') as file:
file.writelines(lines)
return
def update_changelog(path, version):
"""Update changelog file with release info
"""
sys.stdout.write("Update CHANGELOG...\n")
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "## [Unreleased]" in line:
today = datetime.date.today()
lines.insert(index + 1, f"\n## [{version}] - {today}\n")
if line.startswith("[unreleased]"):
last_version = lines[index + 1].split("]")[0][1:]
new_unreleased_line = line.replace(last_version, version)
new_release_line = lines[index + 1].replace(last_version, version)
lines[index] = new_unreleased_line
lines.insert(index + 1, new_release_line)
break
with open(path, 'w+') as file:
file.writelines(lines)
def prepare_release(version, kind):
sys.stdout.write('Let\'s go to create branch to release\n')
branch_to_release = f"release/{version}"
run_command(f"git checkout -b {branch_to_release}", shell=True)
run_command("git pull --rebase origin main", shell=True)
update_changelog("CHANGELOG.md", version)
update_files(version)
run_command("git add CHANGELOG.md", shell=True)
run_command("git add src/", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
if confirm == 'y':
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR and merge code to main than tag version
>> git tag -a v{version}
>> git push origin v{version}
--> Please check docker image: https://hub.docker.com/r/lasuite/people-backend/tags
>> git tag -d preprod
>> git tag preprod
>> git push -f origin preprod
--> Now please generate release on github interface for the current tag v{version}
""")
if __name__ == "__main__":
version, kind = None, None
while not version:
version = input("Enter your release version:")
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
prepare_release(version, kind)
+14
View File
@@ -0,0 +1,14 @@
import subprocess
import sys
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
if msg is None:
msg = f"# Running: {cmd}"
if stdout is not None:
stdout.write(msg + '\n')
if stdout != sys.stdout:
sys.stdout(msg)
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
if stdout is not None:
stdout.flush()
+1 -1
Submodule secrets updated: a31bc360ab...7484f212c5
+2 -2
View File
@@ -146,11 +146,11 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
constraint=models.CheckConstraint(condition=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
constraint=models.CheckConstraint(condition=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
),
migrations.AlterUniqueTogether(
name='contact',
+2 -2
View File
@@ -124,12 +124,12 @@ class Contact(BaseModel):
unique_together = ("owner", "base")
constraints = [
models.CheckConstraint(
check=~models.Q(base__isnull=False, owner__isnull=True),
condition=~models.Q(base__isnull=False, owner__isnull=True),
name="base_owner_constraint",
violation_error_message="A contact overriding a base contact must be owned.",
),
models.CheckConstraint(
check=~models.Q(base=models.F("id")),
condition=~models.Q(base=models.F("id")),
name="base_not_self",
violation_error_message="A contact cannot be based on itself.",
),
+2 -1
View File
@@ -19,7 +19,8 @@ class AuthorizationServerClient:
- Setting appropriate headers for secure communication as recommended by RFC drafts.
"""
# ruff: noqa: PLR0913
# ruff: noqa: PLR0913 PLR017
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-arguments
def __init__(
self,
+1
View File
@@ -4,4 +4,5 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 20,
}
@@ -10,12 +10,15 @@ from uuid import uuid4
from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils.text import slugify
from faker import Faker
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailDomainStatusChoices
fake = Faker()
@@ -152,6 +155,35 @@ def create_demo(stdout):
)
queue.flush()
with Timeit(stdout, "Creating domains"):
for _i in range(defaults.NB_OBJECTS["domains"]):
name = fake.domain_name()
slug = slugify(name)
queue.push(
mailbox_models.MailDomain(
name=name,
# slug should be automatic but bulk_create doesn't use save
slug=slug,
status=random.choice(MailDomainStatusChoices.choices)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating accesses to domains"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domain_id,
user_id=random.choice(users_ids),
role=models.RoleChoices.OWNER,
)
)
queue.flush()
class Command(BaseCommand):
"""A management command to create a demo database."""
@@ -10,12 +10,13 @@ import pytest
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
TEST_NB_OBJECTS = {
"users": 5,
"teams": 3,
"max_identities_per_user": 3,
"max_users_per_team": 5,
"domains": 2,
}
pytestmark = pytest.mark.django_db
@@ -27,9 +28,11 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.User.objects.count() == 5
assert models.Team.objects.count() == 3
assert models.TeamAccess.objects.count() >= 3
assert models.User.objects.count() == TEST_NB_OBJECTS["users"]
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
assert mailbox_models.MailDomainAccess.objects.count() == TEST_NB_OBJECTS["domains"]
def test_commands_createsuperuser():
@@ -21,3 +21,12 @@ class MailBoxPermission(core_permissions.IsAuthenticated):
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
+121 -6
View File
@@ -1,8 +1,14 @@
"""Client serializers for People's mailbox manager app."""
from rest_framework import serializers
import json
from mailbox_manager import models
from rest_framework import exceptions, serializers
from core.api.serializers import UserSerializer
from core.models import User
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
class MailboxSerializer(serializers.ModelSerializer):
@@ -14,6 +20,29 @@ class MailboxSerializer(serializers.ModelSerializer):
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id"]
def create(self, validated_data):
"""
Override create function to fire a request on mailbox creation.
"""
# send new mailbox request to dimail
client = DimailAPIClient()
response = client.send_mailbox_request(
validated_data, self.context["request"].user.sub
)
# fix format to have actual json, and remove uuid
mailbox_data = json.loads(response.content.decode("utf-8").replace("'", '"'))
del mailbox_data["uuid"]
# actually save mailbox on our database
instance = models.Mailbox.objects.create(**validated_data)
# send confirmation email
client.send_new_mailbox_notification(
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
)
return instance
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
@@ -50,7 +79,89 @@ class MailDomainSerializer(serializers.ModelSerializer):
class MailDomainAccessSerializer(serializers.ModelSerializer):
"""Serialize mail domain accesses."""
"""Serialize mail domain access."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
can_set_role_to = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomainAccess
fields = ["id", "user", "role", "can_set_role_to"]
read_only_fields = ["id", "can_set_role_to"]
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_can_set_role_to(self, access):
"""Return roles available to set for the authenticated user"""
return access.get_can_set_role_to(self.context.get("request").user)
def validate(self, attrs):
"""
Check access rights specific to writing (update/create)
"""
request = self.context.get("request")
authenticated_user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_can_set_role_to(authenticated_user)
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to modify role for this user."
)
raise exceptions.PermissionDenied(message)
# Create
else:
# A domain slug has to be set to create a new access
try:
domain_slug = self.context["domain_slug"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a domain slug in kwargs to create a new domain access."
) from exc
try:
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
except models.MailDomainAccess.DoesNotExist as exc:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
) from exc
# Authenticated user must be owner or admin of current domain to set new roles
if access.role not in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
)
# only an owner can set an owner role to another user
if (
role == enums.MailDomainRoleChoices.OWNER
and access.role != enums.MailDomainRoleChoices.OWNER
):
raise exceptions.PermissionDenied(
"Only owners of a domain can assign other users as owners."
)
attrs["user"] = User.objects.get(pk=self.initial_data["user"])
attrs["domain"] = models.MailDomain.objects.get(
slug=self.context["domain_slug"]
)
return attrs
class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
"""Serialize mail domain access for list and retrieve actions."""
class Meta:
model = models.MailDomainAccess
@@ -58,7 +169,11 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
"id",
"user",
"role",
"created_at",
"updated_at",
"can_set_role_to",
]
read_only_fields = [
"id",
"user",
"role",
"can_set_role_to",
]
read_only_fields = ["id"]
+115 -8
View File
@@ -1,11 +1,12 @@
"""API endpoints"""
from rest_framework import filters, mixins, viewsets
from rest_framework import permissions as drf_permissions
from django.db.models import Subquery
from rest_framework import exceptions, filters, mixins, viewsets
from core import models as core_models
from mailbox_manager import models
from mailbox_manager import enums, models
from mailbox_manager.api import permissions, serializers
@@ -54,19 +55,125 @@ class MailDomainViewSet(
# pylint: disable=too-many-ancestors
class MailDomainAccessViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
):
"""
MailDomainAccess viewset.
API ViewSet for all interactions with mail domain accesses.
GET /api/v1.0/mail-domains/<domain_slug>/accesses/:<domain_access_id>
Return list of all domain accesses related to the logged-in user and one
domain access if an id is provided.
POST /api/v1.0/mail-domains/<domain_slug>/accesses/ with expected data:
- user: str
- role: str [owner|admin|viewer]
Return newly created mail domain access
PUT /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
- role: str [owner|admin|viewer]
Return updated domain access
PATCH /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
- role: str [owner|admin|viewer]
Return partially updated domain access
DELETE /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/
Delete targeted domain access
"""
permission_classes = [drf_permissions.IsAuthenticated]
permission_classes = [permissions.MailDomainAccessRolePermission]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "user", "domain", "role"]
ordering_fields = ["role", "user__email", "user__name"]
ordering = ["-created_at"]
queryset = models.MailDomainAccess.objects.all()
queryset = (
models.MailDomainAccess.objects.all()
.select_related("user")
.order_by("-created_at")
)
list_serializer_class = serializers.MailDomainAccessReadOnlySerializer
detail_serializer_class = serializers.MailDomainAccessSerializer
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
context["authenticated_user"] = self.request.user
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
if self.action in {"list", "retrieve"}:
# Determine which role the logged-in user has in the domain
user_role_query = models.MailDomainAccess.objects.filter(
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
).values("role")[:1]
queryset = (
# The logged-in user should be part of a domain to see its accesses
queryset.filter(
domain__accesses__user=self.request.user,
)
# Abilities are computed based on logged-in user's role and
# the user role on each domain access
.annotate(
user_role=Subquery(user_role_query),
)
.select_related("user")
.distinct()
)
return queryset
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != enums.MailDomainRoleChoices.OWNER
):
domain = instance.domain
# Check if the access being updated is the last owner access for the domain
if (
instance.role == enums.MailDomainRoleChoices.OWNER
and domain.accesses.filter(
role=enums.MailDomainRoleChoices.OWNER
).count()
== 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.PermissionDenied({"role": message})
serializer.save()
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
domain = instance.domain
# Check if the access being deleted is the last owner access for the domain
if (
instance.role == enums.MailDomainRoleChoices.OWNER
and domain.accesses.filter(role=enums.MailDomainRoleChoices.OWNER).count()
== 1
):
message = "Cannot delete the last owner access for the domain."
raise exceptions.PermissionDenied({"detail": message})
return super().destroy(request, *args, **kwargs)
class MailBoxViewSet(
-39
View File
@@ -2,14 +2,10 @@
Mailbox manager application factories
"""
import re
from django.utils.text import slugify
import factory.fuzzy
import responses
from faker import Faker
from rest_framework import status
from core import factories as core_factories
from core import models as core_models
@@ -31,7 +27,6 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
name = factory.Faker("domain_name")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
secret = factory.Faker("password")
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -80,37 +75,3 @@ class MailboxFactory(factory.django.DjangoModelFactory):
)
domain = factory.SubFactory(MailDomainEnabledFactory)
secondary_email = factory.Faker("email")
@classmethod
def _create(cls, model_class, *args, use_mock=True, **kwargs):
domain = kwargs["domain"]
if use_mock and isinstance(domain, models.MailDomain):
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(
rf".*/domains/{domain.name}/mailboxes/{kwargs['local_part']}"
),
body=str(
{
"email": f"{kwargs['local_part']}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
result = super()._create(model_class, *args, **kwargs)
else:
result = super()._create(model_class, *args, **kwargs)
return result
@@ -0,0 +1,148 @@
"""Management command creating a dimail-api container, for test purposes."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
import requests
from rest_framework import status
User = get_user_model()
DIMAIL_URL = "http://host.docker.internal:8001"
admin = {"username": "admin", "password": "admin"}
regie = {"username": "la_regie", "password": "password"}
class Command(BaseCommand):
"""
Management command populate local dimail database, to ease dev
"""
help = "Populate local dimail database, for dev purposes."
def handle(self, *args, **options):
"""Handling of the management command."""
if not settings.DEBUG:
raise CommandError(
("This command is meant to run in local dev environment.")
)
# Create a first superuser for dimail-api container. User creation is usually
# protected behind admin rights but dimail allows to create a first user
# when database is empty
self.create_user(
auth=(None, None),
name=admin["username"],
password=admin["password"],
perms=[],
)
# Create Regie user, auth for all remaining requests
# and your own dev
self.create_user(
auth=(admin["username"], admin["password"]),
name=regie["username"],
password=regie["password"],
perms=["new_domain", "create_users", "manage_users"],
)
# we create a dimail user for keycloak+regie user John Doe
# This way, la Régie will be able to make request in the name of
# this user
people_base_user = User.objects.get(name="John Doe")
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
self.create_domain(domain_name)
self.create_allows(people_base_user.sub, domain_name)
self.stdout.write("DONE", ending="\n")
def create_user(
self,
name,
password,
perms=None,
auth=(regie["username"], regie["password"]),
):
"""
Send a request to create a new user.
"""
response = requests.post(
url=f"{DIMAIL_URL}/users/",
json={
"name": name,
"password": password,
"is_admin": name == admin["username"],
"perms": perms or [],
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(self.style.SUCCESS(f"Creating user {name}......... OK"))
else:
self.stdout.write(
self.style.ERROR(
f"Creating user {name} ......... failed: {response.json()['detail']}"
)
)
def create_domain(self, name, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/domains/",
json={
"name": name,
"context_name": "context",
"features": ["webmail", "mailbox", "alias"],
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(f"Creating domain '{name}' ........ OK")
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating domain '{name}' ........ failed: {response.json()['detail']}"
)
)
def create_allows(self, user, domain, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new allows between user and domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/allows/",
json={
"domain": domain,
"user": user,
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(
f"Creating permissions for {user} on {domain} ........ OK"
)
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating permissions for {user} on {domain}\
........ failed: {response.json()['detail']}"
)
)
@@ -0,0 +1,17 @@
# Generated by Django 5.1 on 2024-08-30 12:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0012_alter_mailbox_local_part'),
]
operations = [
migrations.RemoveField(
model_name='maildomain',
name='secret',
),
]
+68 -12
View File
@@ -4,14 +4,13 @@ Declare and configure the models for the People additional application : mailbox
from django.conf import settings
from django.core import exceptions, validators
from django.db import models, transaction
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from core.models import BaseModel
from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
from mailbox_manager.utils.dimail import DimailAPIClient
class MailDomain(BaseModel):
@@ -26,7 +25,6 @@ class MailDomain(BaseModel):
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
class Meta:
db_table = "people_mail_domain"
@@ -49,7 +47,6 @@ class MailDomain(BaseModel):
"""
Compute and return abilities for a given user on the domain.
"""
is_owner_or_admin = False
role = None
if user.is_authenticated:
@@ -105,6 +102,65 @@ class MailDomainAccess(BaseModel):
def __str__(self):
return f"Access of user {self.user} on domain {self.domain}."
def get_can_set_role_to(self, user):
"""Return roles available to set"""
if not user.is_authenticated:
return []
roles = list(MailDomainRoleChoices)
authenticated_user_role = None
# get role of authenticated user
if hasattr(self, "user_role"):
authenticated_user_role = self.user_role
else:
try:
authenticated_user_role = user.mail_domain_accesses.get(
domain=self.domain
).role
except (MailDomainAccess.DoesNotExist, IndexError):
return []
# only an owner can set an owner role
if authenticated_user_role != MailDomainRoleChoices.OWNER:
roles.remove(MailDomainRoleChoices.OWNER)
# if the user authenticated is a viewer, they can't modify role
# and only an owner can change role of an owner
if authenticated_user_role == MailDomainRoleChoices.VIEWER or (
authenticated_user_role != MailDomainRoleChoices.OWNER
and self.role == MailDomainRoleChoices.OWNER
):
return []
# we only want to return other roles available to change,
# so we remove the current role of current access.
roles.remove(self.role)
return sorted(roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the domain access.
"""
role = None
if user.is_authenticated:
try:
role = user.mail_domain_accesses.filter(domain=self.domain).get().role
except (MailDomainAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
return {
"get": bool(role),
"patch": is_owner_or_admin,
"put": is_owner_or_admin,
"post": is_owner_or_admin,
"delete": is_owner_or_admin,
}
class Mailbox(BaseModel):
"""Mailboxes for users from mail domain."""
@@ -139,29 +195,29 @@ class Mailbox(BaseModel):
return f"{self.local_part!s}@{self.domain.name:s}"
def clean(self):
"""Mailboxes can be created only on enabled domains, with a set secret."""
"""
Mailboxes can only be created on enabled domains.
Also, mail-provisioning API credentials must be set for dimail to allow auth.
"""
if self.domain.status != MailDomainStatusChoices.ENABLED:
raise exceptions.ValidationError(
"You can create mailbox only for a domain enabled"
)
if not self.domain.secret:
# Won't be able to query user token if MAIL_PROVISIONING_API_CREDENTIALS are not set
if not settings.MAIL_PROVISIONING_API_CREDENTIALS:
raise exceptions.ValidationError(
"Please configure your domain's secret before creating any mailbox."
"Please configure MAIL_PROVISIONING_API_CREDENTIALS before creating any mailbox."
)
def save(self, *args, **kwargs):
"""
Override save function to fire a request on mailbox creation.
Modification is forbidden for now.
"""
self.full_clean()
if self._state.adding:
with transaction.atomic():
client = DimailAPIClient()
client.send_mailbox_request(self)
return super().save(*args, **kwargs)
return super().save(*args, **kwargs)
# Update is not implemented for now
raise NotImplementedError()
@@ -0,0 +1,173 @@
"""
Test for mail domain accesses API endpoints in People's core app : create
"""
import random
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_create_anonymous():
"""Anonymous users should not be allowed to create mail domain accesses."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = APIClient().post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.MailDomainAccess.objects.exists() is False
def test_api_mail_domain__accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create domain accesses for a domain to
which they are not related.
"""
user = core_factories.UserFactory()
other_user = core_factories.UserFactory()
domain = factories.MailDomainFactory()
client = APIClient()
client.force_login(user)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_viewer():
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
other_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_administrator():
"""
Administrators of a domain should be able to create mail domain accesses
except for the "owner" role.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
other_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": enums.MailDomainRoleChoices.OWNER,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "Only owners of a domain can assign other users as owners."
}
# It should be allowed to create a lower access
for role in [enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.VIEWER]:
other_user = core_factories.UserFactory()
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
new_mail_domain_access = models.MailDomainAccess.objects.filter(
user=other_user
).last()
assert response.json()["id"] == str(new_mail_domain_access.id)
assert response.json()["role"] == role
assert models.MailDomainAccess.objects.filter(domain=mail_domain).count() == 3
def test_api_mail_domain__accesses_create_authenticated_owner():
"""
Owners of a mail domain should be able to create mail domain accesses whatever the role.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.OWNER)]
)
other_user = core_factories.UserFactory()
role = random.choice([role[0] for role in enums.MailDomainRoleChoices.choices])
client = APIClient()
client.force_login(authenticated_user)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert models.MailDomainAccess.objects.filter(user=other_user).count() == 1
new_mail_domain_access = models.MailDomainAccess.objects.filter(
user=other_user
).get()
assert response.json()["id"] == str(new_mail_domain_access.id)
assert response.json()["role"] == role
@@ -0,0 +1,139 @@
"""
Test for mail_domain accesses API endpoints in People's core app : delete
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a mail domain access."""
access = factories.MailDomainAccessFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_viewer():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain in which they are a simple viewer.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
access = factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
def test_api_mail_domain__accesses_delete_administrators():
"""
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
for role in [enums.MailDomainRoleChoices.VIEWER, enums.MailDomainRoleChoices.ADMIN]:
access = factories.MailDomainAccessFactory(domain=mail_domain, role=role)
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners():
"""
An owner should be able to delete the mail domain access of another user including
a owner access.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.OWNER)]
)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
access = factories.MailDomainAccessFactory(domain=mail_domain, role=role)
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a mail domain
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
access = factories.MailDomainAccessFactory(
domain=mail_domain,
user=authenticated_user,
role=enums.MailDomainRoleChoices.OWNER,
)
factories.MailDomainAccessFactory.create_batch(9)
assert models.MailDomainAccess.objects.count() == 10
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 10
@@ -0,0 +1,260 @@
"""
Test for mail_domain accesses API endpoints in People's core app : list
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_list_anonymous():
"""Anonymous users should not be allowed to list mail_domain accesses."""
mail_domain = factories.MailDomainFactory()
factories.MailDomainAccessFactory.create_batch(2, domain=mail_domain)
response = APIClient().get(f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mail_domain__accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
to which they are not related.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
factories.MailDomainAccessFactory.create_batch(3, domain=mail_domain)
# Accesses for other mail_domains to which the user is related should not be listed either
other_access = factories.MailDomainAccessFactory(user=user)
factories.MailDomainAccessFactory(domain=other_access.domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_mail_domain__accesses_list_for_authenticated_user_related_to_domain():
"""
Authenticated users should be able to list mail_domain accesses for a mail_domain
to which they are related, with a given role.
"""
viewer, administrator, owner = core_factories.UserFactory.create_batch(3)
mail_domain = factories.MailDomainFactory()
owner_access = factories.MailDomainAccessFactory.create(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
admin_access = factories.MailDomainAccessFactory.create(
domain=mail_domain, user=administrator, role=enums.MailDomainRoleChoices.ADMIN
)
viewer_access = models.MailDomainAccess.objects.create(
domain=mail_domain, user=viewer, role=enums.MailDomainRoleChoices.VIEWER
)
admin_expected_data = {
"id": str(admin_access.id),
"user": {
"id": str(administrator.id),
"email": str(administrator.email),
"name": str(administrator.name),
},
"role": str(admin_access.role),
}
viewer_expected_data = {
"id": str(viewer_access.id),
"user": {
"id": str(viewer.id),
"email": str(viewer.email),
"name": str(viewer.name),
},
"role": str(viewer_access.role),
}
owner_expected_data = {
"id": str(owner_access.id),
"user": {
"id": str(owner.id),
"email": str(owner.email),
"name": str(owner.name),
},
"role": str(owner_access.role),
}
# Grant other mail_domain accesses to the user, they should not be listed either
other_access = factories.MailDomainAccessFactory(user=viewer)
factories.MailDomainAccessFactory(domain=other_access.domain)
client = APIClient()
client.force_login(viewer)
# viewer can see accesses but no action is available
admin_expected_data["can_set_role_to"] = []
viewer_expected_data["can_set_role_to"] = []
owner_expected_data["can_set_role_to"] = []
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
client.force_login(administrator)
# administrator can see and give new role but not an OWNER role
admin_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.VIEWER]
viewer_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.ADMIN]
owner_expected_data["can_set_role_to"] = []
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
client.force_login(owner)
# owner can do everything
admin_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.VIEWER,
]
viewer_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.OWNER,
]
owner_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
django_assert_num_queries,
):
"""
The number of queries should not depend on the amount of fetched accesses.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user) # random role
client = APIClient()
client.force_login(user)
# Only 3 queries are needed to efficiently fetch mail_domain accesses,
# related users :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - distinct from viewset
with django_assert_num_queries(3):
client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
# create 20 new mail_domain accesses
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
# num queries should still be the same
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 21
def test_api_mail_domain__accesses_list_authenticated_ordering():
"""MailDomain accesses can be ordered by "role"."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user)
# create 20 new mail_domain accesses
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=role",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
results = [access["role"] for access in response.json()["results"]]
assert sorted(results) == results
# check results when we change ordering
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=-role",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
results = [access["role"] for access in response.json()["results"]]
assert sorted(results, reverse=True) == results
@pytest.mark.parametrize("ordering_field", ["email", "name"])
def test_api_mail_domain__accesses_list_authenticated_ordering_user(ordering_field):
"""Mail domain accesses can be ordered by user's fields."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user)
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=user__{ordering_field}",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
def normalize(x):
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
return x.casefold().replace(" ", "")
results = [access["user"][ordering_field] for access in response.json()["results"]]
assert sorted(results, key=normalize) == results
@@ -0,0 +1,118 @@
"""
Test for mail_domain accesses API endpoints in People's core app : retrieve
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a mail_domain access.
"""
access = factories.MailDomainAccessFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a mail_domain access for
a mail_domain to which they are not related.
"""
user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(domain=factories.MailDomainFactory())
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomainAccess matches the given query."}
# Accesses related to another mail_domain should be excluded even if the user is related to it
for other_access in [
factories.MailDomainAccessFactory(),
factories.MailDomainAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{other_access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "No MailDomainAccess matches the given query."
}
def test_api_mail_domain__accesses_retrieve_authenticated_related():
"""
A user who is related to a mail_domain should be allowed to retrieve the
associated mail_domain user accesses.
"""
owner = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
access = factories.MailDomainAccessFactory(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
results = {
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(owner.email),
"name": str(owner.name),
},
"role": str(access.role),
"can_set_role_to": [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
],
}
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
admin = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
).user
client.force_login(admin)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
# admin can't change role of an owner
results["can_set_role_to"] = []
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
viewer = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
).user
client.force_login(viewer)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
# viewer can't change anyone's role
results["can_set_role_to"] = []
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
@@ -0,0 +1,295 @@
"""
Test for mail_domain accesses API endpoints in People's core app : update
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_update_anonymous():
"""An anonymous users should not be allowed to update a mail domain access."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
response = APIClient().put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_unrelated():
"""
An authenticated user should not be allowed to update a mail domain access
for a mail_domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_viewer():
"""A viewer of a mail domain should not be allowed to update accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
access = factories.MailDomainAccessFactory(
domain=mail_domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_viewer_themself():
"""A viewer of a mail domain should not be allowed to update its accesses."""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(
user=authenticated_user,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_administrator_except_owner():
"""
An administrator of a mail domain should be allowed to update a user
access for this mail domain, as long as they don't try to set the role to owner.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
admin_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
)
viewer_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{admin_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
admin_access.refresh_from_db()
assert admin_access.role == enums.MailDomainRoleChoices.ADMIN
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{admin_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.VIEWER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
admin_access.refresh_from_db()
assert admin_access.role == enums.MailDomainRoleChoices.VIEWER
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{viewer_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
viewer_access.refresh_from_db()
assert viewer_access.role == enums.MailDomainRoleChoices.VIEWER
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{viewer_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_200_OK
viewer_access.refresh_from_db()
assert viewer_access.role == enums.MailDomainRoleChoices.ADMIN
def test_api_mail_domain__accesses_update_administrator_from_owner():
"""
An administrator for a mail domain, should not be allowed to update
the user access of an "owner" for this mail domain.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
owner = core_factories.UserFactory()
owner_access = factories.MailDomainAccessFactory(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{owner_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
owner_access.refresh_from_db()
assert owner_access.role == enums.MailDomainRoleChoices.OWNER
def test_api_mail_domain__accesses_update_owner():
"""
An owner of a mail domain should be allowed to update
a user access for this domain.
"""
owner_authenticated = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(owner_authenticated, enums.MailDomainRoleChoices.OWNER)]
)
user_access1 = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
)
user_access2 = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(owner_authenticated)
# turn admin in viewer
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access1.id!s}/",
data={"role": enums.MailDomainRoleChoices.VIEWER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access1.refresh_from_db()
assert user_access1.role == enums.MailDomainRoleChoices.VIEWER
# turn viewer in owner
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access1.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access1.refresh_from_db()
assert user_access1.role == enums.MailDomainRoleChoices.OWNER
# turn viewer in admin
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access2.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access2.refresh_from_db()
assert user_access2.role == enums.MailDomainRoleChoices.ADMIN
def test_api_mail_domain__accesses_update_owner_for_owners():
"""
An owner of a mail domain should be allowed to update
an existing owner access for this mail domain.
"""
owner_authenticated = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(owner_authenticated, enums.MailDomainRoleChoices.OWNER)]
)
other_owner_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner_authenticated)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{other_owner_access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_200_OK
other_owner_access.refresh_from_db()
assert other_owner_access.role == new_role
def test_api_mail_domain__accesses_update_owner_self():
"""
An owner of a mail domain should be allowed to update
their own user access provided there are other owners in the mail domain.
"""
owner_authenticated = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(
user=owner_authenticated, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner_authenticated)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["role"]
== "Cannot change the role to a non-owner role for the last owner access."
)
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.OWNER
# Add another owner and it should now work
factories.MailDomainAccessFactory(
domain=access.domain, role=enums.MailDomainRoleChoices.OWNER
)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_200_OK
access.refresh_from_db()
assert access.role == new_role
@@ -4,6 +4,11 @@ Unit tests for the mailbox API
import json
import re
from logging import Logger
from unittest import mock
from django.test.utils import override_settings
from django.utils.translation import gettext_lazy as _
import pytest
import responses
@@ -230,7 +235,8 @@ def test_api_mailboxes__create_administrator_missing_fields():
assert response.json() == {"secondary_email": ["This field is required."]}
### SYNC TO PROVISIONING API
### REACTING TO DIMAIL-API
### We mock dimail's responses to avoid testing dimail's container too
def test_api_mailboxes__unrelated_user_provisioning_api_not_called():
@@ -285,6 +291,56 @@ def test_api_mailboxes__domain_viewer_provisioning_api_not_called():
assert response.status_code == status.HTTP_403_FORBIDDEN
@mock.patch.object(Logger, "error")
def test_api_mailboxes__async_dimail_unauthorized(mock_error):
"""
Dimail should raise an error if token has been successfully granted
but mailbox creation request returns a 403.
i.e. user exists on dimail-api but has no permission on that domain
"""
# creating all needed objects
# this access somehow exists solely in our database but not in dimail
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK, # user is in dimail-api
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(
rf".*/domains/{access.domain.name}/mailboxes/{mailbox_data['local_part']}"
),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert mock_error.call_count == 1
assert mock_error.call_args_list[0][0] == (
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
access.domain.name,
)
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.OWNER],
@@ -295,7 +351,7 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
"""
Domain owner/admin should be able to create mailboxes.
Provisioning API should be called when owner/admin makes a call.
Expected response contains new email and password.
Succesfull 201 response from dimail should trigger mailbox creation on our side.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=role)
@@ -359,3 +415,246 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
assert mailbox.last_name == mailbox_data["last_name"]
assert mailbox.local_part == mailbox_data["local_part"]
assert mailbox.secondary_email == mailbox_data["secondary_email"]
@override_settings(MAIL_PROVISIONING_API_CREDENTIALS="wrongCredentials")
def test_api_mailboxes__dimail_token_permission_denied():
"""
API should raise a clear "permission denied" error
when receiving a permission denied from dimail upon requesting token.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"details": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
}
assert not models.Mailbox.objects.exists()
def test_api_mailboxes__user_unrelated_to_domain():
"""
API should raise a clear "permission denied" when dimail returns a permission denied
on mailbox creation. This means token was granted for this user
but user is not allowed to modify this domain (i.e. not owner)
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body='{"details": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
}
assert not models.Mailbox.objects.exists()
def test_api_mailboxes__handling_dimail_unexpected_error():
"""
API should raise a clear error when dimail returns an unexpected response.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body='{"details": "Internal server error"}',
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
content_type="application/json",
)
with pytest.raises(SystemError):
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.json() == {
"detail": "Unexpected response from dimail: {'details': 'Internal server error'}"
}
assert not models.Mailbox.objects.exists()
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_api_mailboxes__send_correct_logger_infos(mock_info, mock_error):
"""
Upon requesting mailbox creation, la régie should impersonate
querying user in dimail and log things correctly.
"""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# user sub is sent to payload as a parameter
assert rsps.calls[0].request.params == {"username": access.user.sub}
# Logger
assert not mock_error.called
assert mock_info.call_count == 4
# a new empty error has been added. To be investigated
assert mock_info.call_args_list[0][0] == (
"Token succesfully granted by mail-provisioning API.",
)
assert mock_info.call_args_list[1][0] == (
"Mailbox successfully created on domain %s by user %s",
str(access.domain),
access.user.sub,
)
@mock.patch.object(Logger, "info")
def test_api_mailboxes__sends_new_mailbox_notification(mock_info):
"""
Creating a new mailbox should send confirmation email
to secondary email.
"""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
with mock.patch("django.core.mail.send_mail") as mock_send:
client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert mock_send.call_count == 1
assert mock_send.mock_calls[0][1][0] == "Your new mailbox information"
assert mock_send.mock_calls[0][1][3][0] == mailbox_data["secondary_email"]
assert mock_info.call_count == 4
assert mock_info.call_args_list[2][0] == (
"Information for mailbox %s sent to %s.",
f"{mailbox_data['local_part']}@{access.domain.name}",
mailbox_data["secondary_email"],
)
@@ -2,19 +2,12 @@
Unit tests for the mailbox model
"""
import json
import re
from logging import Logger
from unittest import mock
from django.core import exceptions
from django.test.utils import override_settings
import pytest
import responses
from rest_framework import status
from mailbox_manager import enums, factories, models
from mailbox_manager.api import serializers
pytestmark = pytest.mark.django_db
@@ -140,127 +133,20 @@ def test_models_mailboxes__cannot_be_created_for_pending_maildomain():
factories.MailboxFactory(domain=factories.MailDomainFactory())
### SYNC TO DIMAIL-API
### REACTING TO DIMAIL-API
### We mock dimail's responses to avoid testing dimail's container too
def test_models_mailboxes__no_secret():
"""If no secret is declared on the domain, the function should raise an error."""
domain = factories.MailDomainEnabledFactory(secret=None)
@override_settings(MAIL_PROVISIONING_API_CREDENTIALS=None)
def test_models_mailboxes__dimail_no_credentials():
"""
If MAIL_PROVISIONING_API_CREDENTIALS setting is not configured,
trying to create a mailbox should raise an error.
"""
domain = factories.MailDomainEnabledFactory()
with pytest.raises(
exceptions.ValidationError,
match="Please configure your domain's secret before creating any mailbox.",
match="Please configure MAIL_PROVISIONING_API_CREDENTIALS before creating any mailbox.",
):
factories.MailboxFactory(domain=domain)
def test_models_mailboxes__wrong_secret():
"""If domain secret is inaccurate, the function should raise an error."""
domain = factories.MailDomainEnabledFactory()
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"detail": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body='{"detail": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
with pytest.raises(
exceptions.PermissionDenied,
match=f"Please check secret of the mail domain {domain.name}",
):
mailbox = factories.MailboxFactory(use_mock=False, domain=domain)
# Payload sent to mailbox provider
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
}
@mock.patch.object(Logger, "error")
@mock.patch.object(Logger, "info")
def test_models_mailboxes__create_mailbox_success(mock_info, mock_error):
"""Creating a mailbox sends the expected information and get expected response before saving."""
domain = factories.MailDomainEnabledFactory()
# generate mailbox data before mailbox, to mock responses
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
mailbox = factories.MailboxFactory(
use_mock=False, local_part=mailbox_data["local_part"], domain=domain
)
# Check headers
headers = rsps.calls[1].request.headers
# assert "Authorization" not in headers
assert headers["Content-Type"] == "application/json"
# Payload sent to mailbox provider
payload = json.loads(rsps.calls[1].request.body)
assert payload == {
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
}
# Logger
assert not mock_error.called
assert mock_info.call_count == 2
assert mock_info.call_args_list[0][0] == (
"Token succesfully granted by mail-provisioning API.",
)
assert mock_info.call_args_list[1][0] == (
"Mailbox successfully created on domain %s",
domain.name,
)
assert mock_info.call_args_list[1][1] == (
{
"extra": {
"response": str(
{
"email": f"{mailbox.local_part}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
)
}
}
)
+93 -36
View File
@@ -1,9 +1,13 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
import smtplib
from logging import getLogger
from django.conf import settings
from django.core import exceptions
from django.contrib.sites.models import Site
from django.core import exceptions, mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
import requests
from rest_framework import status
@@ -29,43 +33,59 @@ class DimailAPIClient:
"""A dimail-API client."""
API_URL = settings.MAIL_PROVISIONING_API_URL
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
def get_headers(self, domain):
"""Build header dict from domain object."""
# self.secret is the encoded basic auth, to request a new token from dimail-api
def get_headers(self, user_sub=None):
"""
Build headers dictionary. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
to get a token from dimail /token/ endpoint.
If provided, request user' sub is used for la regie to log in as this user,
thus allowing for more precise logs.
"""
headers = {"Content-Type": "application/json"}
params = None
if user_sub:
params = {"username": str(user_sub)}
response = requests.get(
f"{self.API_URL}/token/",
headers={"Authorization": f"Basic {domain.secret}"},
timeout=status.HTTP_200_OK,
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
params=params,
timeout=20,
)
if response.json() == "{'detail': 'Permission denied'}":
raise exceptions.PermissionDenied(
"This secret does not allow for a new token."
)
if "access_token" in response.json():
if response.status_code == status.HTTP_200_OK:
headers["Authorization"] = f"Bearer {response.json()['access_token']}"
logger.info("Token succesfully granted by mail-provisioning API.")
return headers
return headers
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: Could not retrieve a token,\
please check 'MAIL_PROVISIONING_API_CREDENTIALS' setting.",
)
raise exceptions.PermissionDenied(
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
def send_mailbox_request(self, mailbox):
return self.pass_dimail_unexpected_response(response)
def send_mailbox_request(self, mailbox, user_sub=None):
"""Send a CREATE mailbox request to mail provisioning API."""
payload = {
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
"givenName": mailbox["first_name"],
"surName": mailbox["last_name"],
"displayName": f"{mailbox['first_name']} {mailbox['last_name']}",
}
headers = self.get_headers(user_sub)
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox.domain}/mailboxes/{mailbox.local_part}/",
f"{self.API_URL}/domains/{mailbox['domain']}/mailboxes/{mailbox['local_part']}",
json=payload,
headers=self.get_headers(mailbox.domain),
headers=headers,
verify=True,
timeout=10,
)
@@ -78,27 +98,64 @@ class DimailAPIClient:
raise error
if response.status_code == status.HTTP_201_CREATED:
extra = {"response": response.content.decode("utf-8")}
# This a temporary broken solution. Password will soon be sent
# from OX servers but their prod is not ready.
# In the meantime, we log mailbox info (including password !)
logger.info(
"Mailbox successfully created on domain %s",
mailbox.domain.name,
extra=extra,
"Mailbox successfully created on domain %s by user %s",
str(mailbox["domain"]),
user_sub,
)
elif response.status_code == status.HTTP_403_FORBIDDEN:
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: please check the mail domain secret of %s",
mailbox.domain.name,
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(mailbox["domain"]),
)
raise exceptions.PermissionDenied(
f"Please check secret of the mail domain {mailbox.domain.name}"
)
else:
logger.error(
"Unexpected response: %s",
response.content.decode("utf-8"),
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
return response
return self.pass_dimail_unexpected_response(response)
def pass_dimail_unexpected_response(self, response):
"""Raise error when encountering an unexpected error in dimail."""
error_content = response.content.decode("utf-8")
logger.error("[DIMAIL] unexpected error : %s", error_content)
raise SystemError(f"Unexpected response from dimail: {error_content}")
def send_new_mailbox_notification(self, recipient, mailbox_data):
"""
Send email to confirm mailbox creation
and send new mailbox information.
"""
template_vars = {
"title": _("Your new mailbox information"),
"site": Site.objects.get_current(),
"webmail_url": settings.WEBMAIL_URL,
"mailbox_data": mailbox_data,
}
msg_html = render_to_string("mail/html/new_mailbox.html", template_vars)
msg_plain = render_to_string("mail/text/new_mailbox.txt", template_vars)
try:
mail.send_mail(
template_vars["title"],
msg_plain,
settings.EMAIL_FROM,
[recipient],
html_message=msg_html,
fail_silently=False,
)
logger.info(
"Information for mailbox %s sent to %s.",
mailbox_data["email"],
recipient,
)
except smtplib.SMTPException as exception:
logger.error(
"Mailbox confirmation email to %s was not sent: %s",
recipient,
exception,
)
+28 -5
View File
@@ -265,10 +265,7 @@ class Base(Configuration):
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None)
EMAIL_HOST_USER = values.Value(None)
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
@@ -415,12 +412,22 @@ class Base(Configuration):
OIDC_TIMEOUT = values.Value(None, environ_name="OIDC_TIMEOUT", environ_prefix=None)
# mailboxes provisioning API
# MAILBOX-PROVISIONING API
WEBMAIL_URL = values.Value(
default=None,
environ_name="WEBMAIL_URL",
environ_prefix=None,
)
MAIL_PROVISIONING_API_URL = values.Value(
default="https://api.dev.ox.numerique.gouv.fr",
default="http://host.docker.internal:8001",
environ_name="MAIL_PROVISIONING_API_URL",
environ_prefix=None,
)
MAIL_PROVISIONING_API_CREDENTIALS = values.Value(
default=None,
environ_name="MAIL_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
FEATURES = {
"TEAMS": values.BooleanValue(
@@ -473,6 +480,7 @@ class Base(Configuration):
environment=cls.__name__.lower(),
release=get_release(),
integrations=[DjangoIntegration()],
traces_sample_rate=1.0,
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
@@ -574,6 +582,9 @@ class Test(Base):
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
# this is a dev credentials for mail provisioning API
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
class ContinuousIntegration(Test):
"""
@@ -608,6 +619,14 @@ class Production(Base):
#
# In other cases, you should comment the following line to avoid security issues.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
@@ -654,6 +673,10 @@ class Production(Base):
},
},
}
SENTRY_DSN = values.Value(
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
environ_name="SENTRY_DSN",
)
class Feature(Production):
+13 -13
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.0.2"
version = "1.2.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,31 +25,31 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.7",
"boto3==1.35.29",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.8",
"redis==5.1.0",
"django-redis==5.4.0",
"django-storages==1.14.4",
"django-timezone-field>=5.1",
"django==5.1",
"django==5.1.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.1",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"joserfc==1.0.0",
"requests==2.32.3",
"sentry-sdk==2.13.0",
"sentry-sdk[django]==2.14.0",
"url-normalize==1.4.3",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
@@ -66,18 +66,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.26.0",
"ipython==8.27.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.2.6",
"pylint==3.3.1",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest==8.3.2",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.2",
"types-requests==2.32.0.20240712",
"ruff==0.6.8",
"types-requests==2.32.0.20240914",
"freezegun==1.5.1",
]
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.0.2",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -18,17 +18,17 @@
"@gouvfr-lasuite/integration": "1.0.2",
"@hookform/resolvers": "3.9.0",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.52.3",
"i18next": "23.14.0",
"@tanstack/react-query": "5.56.2",
"i18next": "23.15.1",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.7",
"next": "14.2.13",
"react": "*",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-hook-form": "7.53.0",
"react-i18next": "15.0.1",
"react-select": "5.8.0",
"react-i18next": "15.0.2",
"react-select": "5.8.1",
"styled-components": "6.1.13",
"zod": "3.23.8",
"zustand": "4.5.5"
@@ -36,16 +36,16 @@
"devDependencies": {
"@hookform/devtools": "4.3.1",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.52.3",
"@tanstack/react-query-devtools": "5.58.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.7",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.9",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.4",
"@types/react": "18.3.10",
"@types/react-dom": "*",
"dotenv": "16.4.5",
"eslint-config-people": "*",
@@ -0,0 +1,157 @@
import { APIError } from '@/api';
import {
parseAPIError,
parseAPIErrorCause,
parseServerAPIError,
} from '../parseAPIError';
describe('parseAPIError', () => {
const handleErrorMock = jest.fn();
const handleServerErrorMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle specific API error and return no unhandled causes', () => {
const error = new APIError('client error', {
cause: ['Mail domain with this name already exists.'],
status: 400,
});
const result = parseAPIError({
error,
errorParams: [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
],
serverErrorParams: ['Server error', handleServerErrorMock],
});
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual(['This domain already exists.']);
});
it('should return unhandled causes when no match is found', () => {
const error = new APIError('client error', {
cause: ['Unhandled error'],
status: 400,
});
const result = parseAPIError({
error,
errorParams: [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
],
serverErrorParams: ['Server error', handleServerErrorMock],
});
expect(handleErrorMock).not.toHaveBeenCalled();
expect(result).toEqual(['Unhandled error']);
});
it('should handle server errors correctly and prepend server error message', () => {
const error = new APIError('server error', { status: 500 });
const result = parseAPIError({
error,
errorParams: undefined,
serverErrorParams: ['Server error occurred', handleServerErrorMock],
});
expect(handleServerErrorMock).toHaveBeenCalled();
expect(result).toEqual(['Server error occurred']);
});
it('should handle absence of errors gracefully', () => {
const result = parseAPIError({
error: null,
errorParams: [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
],
serverErrorParams: ['Server error', handleServerErrorMock],
});
expect(result).toBeUndefined();
});
});
describe('parseAPIErrorCause', () => {
it('should handle specific errors and call handleError', () => {
const handleErrorMock = jest.fn();
const causes = ['Mail domain with this name already exists.'];
const errorParams: [string[], string, () => void][] = [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
];
const result = parseAPIErrorCause(
new APIError('client error', { cause: causes, status: 400 }),
errorParams,
);
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual(['This domain already exists.']);
});
it('should handle multiple causes and return unhandled causes', () => {
const handleErrorMock = jest.fn();
const causes = [
'Mail domain with this name already exists.',
'Unhandled error',
];
const errorParams: [string[], string, () => void][] = [
[
['Mail domain with this name already exists.'],
'This domain already exists.',
handleErrorMock,
],
];
const result = parseAPIErrorCause(
new APIError('client error', { cause: causes, status: 400 }),
errorParams,
);
expect(handleErrorMock).toHaveBeenCalled();
expect(result).toEqual(['This domain already exists.', 'Unhandled error']);
});
});
describe('parseServerAPIError', () => {
const handleServerErrorMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the server error message and handle callback', () => {
const result = parseServerAPIError(['Server error', handleServerErrorMock]);
expect(result).toEqual('Server error');
expect(handleServerErrorMock).toHaveBeenCalled();
});
it('should return only the server error message when no callback is provided', () => {
const result = parseServerAPIError(['Server error', undefined]);
expect(result).toEqual('Server error');
});
});
@@ -0,0 +1,56 @@
import { act, renderHook } from '@testing-library/react';
import { useDebounce } from '../useDebounce';
jest.useFakeTimers();
describe('useDebounce', () => {
it('should return the initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial value'));
expect(result.current).toBe('initial value');
});
it('should return debounced value after default 500ms delay', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'initial' },
});
expect(result.current).toBe('initial');
rerender({ value: 'updated' });
expect(result.current).toBe('initial');
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('updated');
});
it('should reset the debounce timer if value changes before delay', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'initial' },
});
expect(result.current).toBe('initial');
rerender({ value: 'updated' });
rerender({ value: 'updated again' });
// Fast forward 400ms (less than debounce delay), value should still be 'initial'
act(() => {
jest.advanceTimersByTime(400);
});
expect(result.current).toBe('initial');
// Fast forward 500ms (total: 900ms), value should be 'updated again'
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('updated again');
});
});
@@ -0,0 +1 @@
export { useDebounce } from './useDebounce';
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
const MS_DEBOUNCE = 500;
export const useDebounce = (value: string, delay: number = MS_DEBOUNCE) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
+1
View File
@@ -3,3 +3,4 @@ export * from './conf';
export * from './fetchApi';
export * from './types';
export * from './utils';
export * from './hooks';
@@ -0,0 +1,106 @@
import { APIError } from '@/api/index';
type ErrorCallback = () => void;
// Type for the error tuple format [causes, message, handleError]
type ErrorTuple = [string[], string, ErrorCallback | undefined];
// Server error tuple [defaultMessage, handleError]
type ServerErrorTuple = [string, ErrorCallback | undefined];
/**
* @function parseAPIError
* @description function to centralize APIError handling to treat discovered errors
* and error type 500 with default behavior using a simplified tuple structure.
* @param error - APIError object
* @param errorParams - Array of tuples: each contains an array of causes, a message, and an optional callback function.
* @param serverErrorParams - A tuple for server error handling: [defaultMessage, handleError]
* @returns Array of error messages or undefined
*/
export const parseAPIError = ({
error,
errorParams,
serverErrorParams,
}: {
error: APIError | null;
errorParams?: ErrorTuple[];
serverErrorParams?: ServerErrorTuple;
}): string[] | undefined => {
if (!error) {
return;
}
// Parse known error causes using the tuple structure
const errorCauses =
error.cause?.length && errorParams
? parseAPIErrorCause(error, errorParams)
: undefined;
// Check if it's a server error (500) and handle that case
const serverErrorCause =
(error?.status === 500 || !error?.status) && serverErrorParams
? parseServerAPIError(serverErrorParams)
: undefined;
// Combine the causes and return
const causes: string[] = errorCauses ? [...errorCauses] : [];
if (serverErrorCause) {
causes.unshift(serverErrorCause);
}
return causes.length ? causes : undefined;
};
/**
* @function parseAPIErrorCause
* @description Processes known API error causes using the tuple structure.
* @param error - APIError object
* @param errorParams - Array of tuples: each contains an array of causes, a message, and an optional callback function.
* @returns Array of error messages
*/
export const parseAPIErrorCause = (
error: APIError,
errorParams: ErrorTuple[],
): string[] | undefined => {
if (!error.cause) {
return;
}
return error.cause.reduce((causes: string[], cause: string) => {
// Find the matching error tuple
const matchedError = errorParams.find(([errorCauses]) =>
errorCauses.some((knownCause) => new RegExp(knownCause, 'i').test(cause)),
);
if (matchedError) {
const [, message, handleError] = matchedError;
causes.push(message);
if (handleError) {
handleError();
}
} else {
// If no match is found, add the original cause
causes.push(cause);
}
return causes;
}, []);
};
/**
* @function parseServerAPIError
* @description Handles server errors (500) and adds the default message.
* @param serverErrorParams - Tuple [defaultMessage, handleError]
* @returns Server error message
*/
export const parseServerAPIError = ([
defaultMessage,
handleError,
]: ServerErrorTuple): string => {
if (handleError) {
handleError();
}
return defaultMessage;
};
@@ -0,0 +1,42 @@
import {
Modal as CunninghamModal,
ModalProps,
} from '@openfun/cunningham-react';
import React, { useEffect } from 'react';
// Define a wrapper component that extends ModalProps to accept the same props as the Modal
export const Modal: React.FC<ModalProps> = ({ children, ...props }) => {
// Apply the hook here once for all modals
usePreventFocusVisible(['.c__modal__content']);
return <CunninghamModal {...props}>{children}</CunninghamModal>;
};
/**
* @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
*/
export const usePreventFocusVisible = (elements: string[]) => {
useEffect(() => {
const observer = new MutationObserver((mutationsList) => {
mutationsList.forEach(() => {
elements.forEach((selector) =>
document.querySelector(selector)?.setAttribute('tabindex', '-1'),
);
observer.disconnect();
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [elements]);
return null;
};
@@ -0,0 +1,80 @@
import { ModalSize } from '@openfun/cunningham-react';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { Modal, usePreventFocusVisible } from '../Modal';
describe('usePreventFocusVisible hook', () => {
const TestComponent = () => {
usePreventFocusVisible(['.test-element']);
return (
<div>
<div className="test-element">Test Element</div>
</div>
);
};
const originalMutationObserver = global.MutationObserver;
const mockDisconnect = jest.fn();
const mutationObserverMock = jest.fn(function MutationObserver(
callback: MutationCallback,
) {
this.observe = () => {
callback([{ type: 'childList' }] as MutationRecord[], this);
};
this.disconnect = mockDisconnect;
});
afterEach(() => jest.clearAllMocks());
beforeAll(
() =>
(global.MutationObserver =
mutationObserverMock as unknown as typeof MutationObserver),
);
afterAll(() => (global.MutationObserver = originalMutationObserver));
test('sets tabindex to -1 on the target elements', () => {
const { unmount } = render(<TestComponent />);
const targetElement = screen.getByText('Test Element');
expect(targetElement).toHaveAttribute('tabindex', '-1');
unmount();
expect(mockDisconnect).toHaveBeenCalled();
});
});
describe('Modal', () => {
test('applies usePreventFocusVisible and sets tabindex', async () => {
render(
<Modal
isOpen={true}
onClose={() => {}}
size={ModalSize.MEDIUM}
title={<h3>Test Modal Title</h3>}
leftActions={<button>Cancel</button>}
rightActions={<button>Submit</button>}
>
<p>Modal content</p>
</Modal>,
{ wrapper: AppWrapper },
);
/* eslint-disable testing-library/no-node-access */
const modalContent = document.querySelector('.c__modal__content');
/* eslint-enable testing-library/no-node-access */
await waitFor(() => {
expect(modalContent).toHaveAttribute('tabindex', '-1');
});
});
});
@@ -9,6 +9,9 @@ import { useConfigStore } from '../config';
jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
usePathname: () => '/',
useRouter: () => ({
push: () => {},
}),
}));
describe('MainLayout', () => {
@@ -20,19 +23,23 @@ describe('MainLayout', () => {
render(<MainLayout />, { wrapper: AppWrapper });
expect(
screen.getByRole('link', {
screen.getByRole('button', {
name: /Teams button/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole('link', {
screen.getByRole('button', {
name: /Mail Domains button/i,
}),
).toBeInTheDocument();
});
it('checks menu rendering without team feature', () => {
useConfigStore.setState({
config: { FEATURES: { TEAMS: false }, LANGUAGES: [] },
});
render(<MainLayout />, { wrapper: AppWrapper });
expect(
@@ -484,11 +484,6 @@ input:-webkit-autofill:focus {
/**
* Modal
*/
.c__modal:focus-visible {
outline: none;
box-shadow: none;
}
.c__modal__backdrop {
z-index: 1000;
}
@@ -7,32 +7,35 @@ import { useAuthStore } from '@/core/auth';
export const AccountDropdown = () => {
const { t } = useTranslation();
const { logout } = useAuthStore();
const { userData, logout } = useAuthStore();
const userName = userData?.name || t('No Username');
return (
<DropButton
aria-label={t('My account')}
button={
<Box $flex $direction="row" $align="center">
<Text $theme="primary">{t('My account')}</Text>
<Text $theme="primary">{userName}</Text>
<Text className="material-icons" $theme="primary" aria-hidden="true">
arrow_drop_down
</Text>
</Box>
}
>
<Button
onClick={logout}
color="primary-text"
icon={
<span className="material-icons" aria-hidden="true">
logout
</span>
}
aria-label={t('Logout')}
>
<Text $weight="normal">{t('Logout')}</Text>
</Button>
<Box $css="display: flex; direction: column; gap: 0.5rem">
<Button
onClick={logout}
key="logout"
color="primary-text"
icon={
<span className="material-icons" aria-hidden="true">
logout
</span>
}
aria-label={t('Logout')}
>
<Text $weight="normal">{t('Logout')}</Text>
</Button>
</Box>
</DropButton>
);
};
@@ -0,0 +1,67 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '@/core/auth';
import { AppWrapper } from '@/tests/utils';
import { AccountDropdown } from '../AccountDropdown';
describe('AccountDropdown', () => {
const mockLogout = jest.fn();
const renderAccountDropdown = () =>
render(<AccountDropdown />, { wrapper: AppWrapper });
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({
userData: {
id: '1',
email: 'test@example.com',
name: 'Test User',
},
logout: mockLogout,
});
});
it('renders the user name correctly', async () => {
renderAccountDropdown();
expect(await screen.findByText('Test User')).toBeInTheDocument();
});
it('renders "No Username" when userData name is missing', () => {
useAuthStore.setState({
userData: {
id: '1',
email: 'test@example.com',
},
logout: mockLogout,
});
renderAccountDropdown();
expect(screen.getByText('No Username')).toBeInTheDocument();
});
it('opens the dropdown and shows logout button when clicked', async () => {
renderAccountDropdown();
const dropButton = await screen.findByText('Test User');
await userEvent.click(dropButton);
expect(screen.getByText('Logout')).toBeInTheDocument();
expect(screen.getByLabelText('Logout')).toBeInTheDocument();
});
it('calls logout function when logout button is clicked', async () => {
renderAccountDropdown();
const dropButton = await screen.findByText('Test User');
await userEvent.click(dropButton);
const logoutButton = screen.getByLabelText('Logout');
await userEvent.click(logoutButton);
expect(mockLogout).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,6 @@
import { Select } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -60,6 +60,19 @@ export const LanguagePicker = () => {
}));
}, [languages]);
/**
* @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
*/
useEffect(() => {
if (!document) {
return;
}
document
.querySelector('.c__select-language-picker .c__select__wrapper')
?.setAttribute('tabindex', '-1');
}, []);
return (
<SelectStyled
label={t('Language')}
@@ -67,7 +80,7 @@ export const LanguagePicker = () => {
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg"
className="c_select__no_bg c__select-language-picker"
options={optionsPicker}
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
@@ -2,4 +2,4 @@ export * from './useMailDomains';
export * from './useMailDomain';
export * from './useCreateMailbox';
export * from './useMailboxes';
export * from './useCreateMailDomain';
export * from './useAddMailDomain';
@@ -5,7 +5,13 @@ import { MailDomain } from '@/features/mail-domains';
import { KEY_LIST_MAIL_DOMAIN } from './useMailDomains';
export const createMailDomain = async (name: string): Promise<MailDomain> => {
export interface AddMailDomainParams {
name: string;
}
export const addMailDomain = async (
name: AddMailDomainParams['name'],
): Promise<MailDomain> => {
const response = await fetchAPI(`mail-domains/`, {
method: 'POST',
body: JSON.stringify({
@@ -23,19 +29,24 @@ export const createMailDomain = async (name: string): Promise<MailDomain> => {
return response.json() as Promise<MailDomain>;
};
export function useCreateMailDomain({
export const useAddMailDomain = ({
onSuccess,
onError,
}: {
onSuccess: (data: MailDomain) => void;
}) {
onError: (error: APIError) => void;
}) => {
const queryClient = useQueryClient();
return useMutation<MailDomain, APIError, string>({
mutationFn: createMailDomain,
mutationFn: addMailDomain,
onSuccess: (data) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN],
});
onSuccess(data);
},
onError: (error) => {
onError(error);
},
});
}
};
@@ -26,7 +26,6 @@ export const createMailbox = async ({
});
if (!response.ok) {
// TODO: extend errorCauses to return the name of the invalid field names to highlight in the form?
throw new APIError(
'Failed to create the mailbox',
await errorCauses(response),
@@ -40,7 +39,7 @@ type UseCreateMailboxParams = { mailDomainSlug: string } & UseMutationOptions<
CreateMailboxParams
>;
export function useCreateMailbox(options: UseCreateMailboxParams) {
export const useCreateMailbox = (options: UseCreateMailboxParams) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, CreateMailboxParams>({
mutationFn: createMailbox,
@@ -61,4 +60,4 @@ export function useCreateMailbox(options: UseCreateMailboxParams) {
}
},
});
}
};
@@ -19,7 +19,7 @@ import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
import { PAGE_SIZE } from '../conf';
import { MailDomain, MailDomainMailbox } from '../types';
import { CreateMailboxForm } from './forms/CreateMailboxForm';
import { ModalCreateMailbox } from './ModalCreateMailbox';
export type ViewMailbox = {
name: string;
@@ -87,7 +87,7 @@ export function MailDomainsContent({ mailDomain }: { mailDomain: MailDomain }) {
) : (
<>
{isCreateMailboxFormVisible && mailDomain ? (
<CreateMailboxForm
<ModalCreateMailbox
mailDomain={mailDomain}
closeModal={() => setIsCreateMailboxFormVisible(false)}
/>
@@ -1,29 +1,27 @@
import { zodResolver } from '@hookform/resolvers/zod';
import {
Button,
Input,
Loader,
Modal,
ModalSize,
} from '@openfun/cunningham-react';
import { Button, Input, Loader, ModalSize } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import React from 'react';
import React, { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Box, StyledLink, Text, TextErrors } from '@/components';
import { useCreateMailDomain } from '@/features/mail-domains';
import { parseAPIError } from '@/api/parseAPIError';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { useAddMailDomain } from '@/features/mail-domains';
import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
const FORM_ID = 'form-add-mail-domain';
export const ModalCreateMailDomain = () => {
export const ModalAddMailDomain = () => {
const { t } = useTranslation();
const router = useRouter();
const createMailDomainValidationSchema = z.object({
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const addMailDomainValidationSchema = z.object({
name: z.string().min(1, t('Example: saint-laurent.fr')),
});
@@ -34,41 +32,66 @@ export const ModalCreateMailDomain = () => {
},
mode: 'onChange',
reValidateMode: 'onChange',
resolver: zodResolver(createMailDomainValidationSchema),
resolver: zodResolver(addMailDomainValidationSchema),
});
const {
mutate: createMailDomain,
isPending,
error,
} = useCreateMailDomain({
const { mutate: addMailDomain, isPending } = useAddMailDomain({
onSuccess: (mailDomain) => {
router.push(`/mail-domains/${mailDomain.slug}`);
},
onError: (error) => {
const unhandledCauses = parseAPIError({
error,
errorParams: [
[
[
'Mail domain with this name already exists.',
'Mail domain with this Slug already exists.',
],
'',
() => {
if (methods.formState.errors.name) {
return;
}
methods.setError('name', {
type: 'manual',
message: t(
'This mail domain is already used. Please, choose another one.',
),
});
methods.setFocus('name');
},
],
],
serverErrorParams: [
t(
'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',
),
() => {
methods.setFocus('name');
},
],
});
setErrorCauses((prevState) =>
unhandledCauses &&
JSON.stringify(unhandledCauses) !== JSON.stringify(prevState)
? unhandledCauses
: prevState,
);
},
});
const onSubmitCallback = () => {
void methods.handleSubmit(({ name }, event) => {
event?.preventDefault();
void createMailDomain(name);
const onSubmitCallback = (event: React.FormEvent) => {
event.preventDefault();
void methods.handleSubmit(({ name }) => {
void addMailDomain(name);
})();
};
const causes = error?.cause?.filter((cause) => {
const isFound = cause === 'Mail domain with this name already exists.';
if (isFound) {
methods.setError('name', {
type: 'manual',
message: t(
'This mail domain is already used. Please, choose another one.',
),
});
}
return !isFound;
});
if (!methods) {
return null;
}
@@ -77,20 +100,23 @@ export const ModalCreateMailDomain = () => {
<Modal
isOpen
leftActions={
<StyledLink href="/mail-domains">
<Button color="secondary" tabIndex={-1}>
{t('Cancel')}
</Button>
</StyledLink>
<Button color="secondary" onClick={() => router.push('/mail-domains/')}>
{t('Cancel')}
</Button>
}
hideCloseButton
closeOnClickOutside
closeOnEsc
onClose={() => router.push('/mail-domains')}
onClose={() => router.push('/mail-domains/')}
rightActions={
<Button
onClick={onSubmitCallback}
disabled={!methods.watch('name') || isPending}
type="submit"
form={FORM_ID}
disabled={
methods.formState.isSubmitting ||
!methods.formState.isValid ||
isPending
}
>
{t('Add the domain')}
</Button>
@@ -100,13 +126,25 @@ export const ModalCreateMailDomain = () => {
<>
<MailDomainsLogo aria-hidden="true" />
<Text as="h3" $textAlign="center">
{t('Add your mail domain')}
{t('Add a mail domain')}
</Text>
</>
}
>
{!!errorCauses?.length ? (
<TextErrors
$margin={{ bottom: 'small' }}
$textAlign="left"
causes={errorCauses}
/>
) : null}
<FormProvider {...methods}>
<form action="" id={FORM_ID}>
<form
id={FORM_ID}
onSubmit={onSubmitCallback}
title={t('Mail domain addition form')}
>
<Controller
control={methods.control}
name="name"
@@ -130,7 +168,6 @@ export const ModalCreateMailDomain = () => {
)}
/>
</form>
{!!causes?.length ? <TextErrors causes={causes} /> : null}
{isPending && (
<Box $align="center">
@@ -2,12 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import {
Button,
Input,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React from 'react';
import React, { useState } from 'react';
import {
Controller,
FormProvider,
@@ -18,10 +17,12 @@ import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { z } from 'zod';
import { parseAPIError } from '@/api/parseAPIError';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { CreateMailboxParams, useCreateMailbox } from '../../api';
import { MailDomain } from '../../types';
import { CreateMailboxParams, useCreateMailbox } from '../api';
import { MailDomain } from '../types';
const FORM_ID: string = 'form-create-mailbox';
@@ -32,7 +33,7 @@ const GlobalStyle = createGlobalStyle`
}
`;
export const CreateMailboxForm = ({
export const ModalCreateMailbox = ({
mailDomain,
closeModal,
}: {
@@ -42,6 +43,8 @@ export const CreateMailboxForm = ({
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const messageInvalidMinChar = t('You must have minimum 1 character');
const createMailboxValidationSchema = z.object({
@@ -77,7 +80,7 @@ export const CreateMailboxForm = ({
resolver: zodResolver(createMailboxValidationSchema),
});
const { mutate: createMailbox, error } = useCreateMailbox({
const { mutate: createMailbox, isPending } = useCreateMailbox({
mailDomainSlug: mailDomain.slug,
onSuccess: () => {
toast(t('Mailbox created!'), VariantType.SUCCESS, {
@@ -86,6 +89,48 @@ export const CreateMailboxForm = ({
closeModal();
},
onError: (error) => {
const causes = parseAPIError({
error,
errorParams: [
[
['Mailbox with this Local_part and Domain already exists.'],
'',
() => {
methods.setError('local_part', {
type: 'manual',
message: t('This email prefix is already used.'),
});
methods.setFocus('local_part');
},
],
[
[
"Please configure your domain's secret before creating any mailbox.",
'Secret not valid for this domain',
],
t(
'The mail domain secret is misconfigured. Please, contact ' +
'our support team to solve the issue: suiteterritoriale@anct.gouv.fr',
),
() => methods.setFocus('first_name'),
],
],
serverErrorParams: [
t(
'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',
),
() => methods.setFocus('first_name'),
],
});
setErrorCauses((prevState) =>
causes && JSON.stringify(causes) !== JSON.stringify(prevState)
? causes
: prevState,
);
},
});
const onSubmitCallback = (event: React.FormEvent) => {
@@ -95,20 +140,6 @@ export const CreateMailboxForm = ({
)();
};
const causes = error?.cause?.filter((cause) => {
const isFound =
cause === 'Mailbox with this Local_part and Domain already exists.';
if (isFound) {
methods.setError('local_part', {
type: 'manual',
message: t('This email prefix is already used.'),
});
}
return !isFound;
});
return (
<FormProvider {...methods}>
<Modal
@@ -132,7 +163,11 @@ export const CreateMailboxForm = ({
fullWidth
type="submit"
form={FORM_ID}
disabled={methods.formState.isSubmitting}
disabled={
methods.formState.isSubmitting ||
!methods.formState.isValid ||
isPending
}
>
{t('Create the mailbox')}
</Button>
@@ -152,8 +187,12 @@ export const CreateMailboxForm = ({
>
<GlobalStyle />
<Box $width="100%" $margin={{ top: 'none', bottom: 'xl' }}>
{!!causes?.length && (
<TextErrors $margin={{ bottom: 'small' }} causes={causes} />
{!!errorCauses?.length && (
<TextErrors
$margin={{ bottom: 'small' }}
causes={errorCauses}
$textAlign="left"
/>
)}
<Text
$margin={{ horizontal: 'none', vertical: 'big' }}
@@ -188,7 +227,11 @@ const Form = ({
const { t } = useTranslation();
return (
<form onSubmit={onSubmitCallback} id={FORM_ID}>
<form
onSubmit={onSubmitCallback}
id={FORM_ID}
title={t('Mailbox creation form')}
>
<Box $direction="column" $width="100%" $gap="2rem" $margin="auto">
<Box $margin={{ horizontal: 'none' }}>
<FieldMailBox
@@ -0,0 +1,241 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { ModalAddMailDomain } from '../ModalAddMailDomain';
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: jest.fn().mockImplementation(() => ({
push: mockPush,
})),
}));
describe('ModalAddMailDomain', () => {
const getElements = () => ({
modalElement: screen.getByText('Add a mail domain'),
formTag: screen.getByTitle('Mail domain addition form'),
inputName: screen.getByLabelText(/Domain name/i),
buttonCancel: screen.getByRole('button', { name: /Cancel/i, hidden: true }),
buttonSubmit: screen.getByRole('button', {
name: /Add the domain/i,
hidden: true,
}),
});
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
it('renders all the elements', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { modalElement, formTag, inputName, buttonCancel, buttonSubmit } =
getElements();
expect(modalElement).toBeVisible();
expect(formTag).toBeVisible();
expect(inputName).toBeVisible();
expect(screen.getByText('Example: saint-laurent.fr')).toBeVisible();
expect(buttonCancel).toBeVisible();
expect(buttonSubmit).toBeVisible();
});
it('should disable submit button when no field is filled', () => {
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { buttonSubmit } = getElements();
expect(buttonSubmit).toBeDisabled();
});
it('displays validation error on empty submit', async () => {
fetchMock.mock(`end:mail-domains/`, 201);
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.clear(inputName);
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(/Example: saint-laurent.fr/i),
).toBeInTheDocument();
});
expect(fetchMock.lastUrl()).toBeFalsy();
});
it('submits the form when validation passes', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 201,
body: {
name: 'domain.fr',
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
slug: 'domainfr',
status: 'enabled',
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
},
});
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.click(buttonSubmit);
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',
});
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,
body: {
name: 'Mail domain with this name already exists.',
},
});
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(
/This mail domain is already used. Please, choose another one./i,
),
).toBeInTheDocument();
});
expect(inputName).toHaveFocus();
await user.type(inputName, 'domain2.fr');
expect(buttonSubmit).toBeEnabled();
});
it('displays right error message error when maildomain slug is already used', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 400,
body: {
name: 'Mail domain with this Slug already exists.',
},
});
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domainfr');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(
/This mail domain is already used. Please, choose another one./i,
),
).toBeInTheDocument();
});
expect(inputName).toHaveFocus();
await user.type(inputName, 'domain2fr');
expect(buttonSubmit).toBeEnabled();
});
it('displays right error message error when error 500 is received', async () => {
fetchMock.mock(`end:mail-domains/`, {
status: 500,
});
const user = userEvent.setup();
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
const { inputName, buttonSubmit } = getElements();
await user.type(inputName, 'domain.fr');
await user.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(
'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',
),
).toBeInTheDocument();
});
expect(inputName).toHaveFocus();
expect(buttonSubmit).toBeEnabled();
});
});
@@ -0,0 +1,237 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { MailDomain } from '../../types';
import { ModalCreateMailbox } from '../ModalCreateMailbox';
const mockMailDomain: MailDomain = {
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
name: 'example.com',
slug: 'example-com',
status: 'enabled',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
abilities: {
get: true,
patch: true,
put: true,
post: true,
delete: true,
manage_accesses: true,
},
};
const toast = jest.fn();
jest.mock('@openfun/cunningham-react', () => ({
...jest.requireActual('@openfun/cunningham-react'),
useToastProvider: () => ({
toast,
}),
}));
describe('ModalCreateMailbox', () => {
const mockCloseModal = jest.fn();
const apiUrl = `end:/mail-domains/${mockMailDomain.slug}/mailboxes/`;
const renderModalCreateMailbox = () => {
return render(
<ModalCreateMailbox
mailDomain={mockMailDomain}
closeModal={mockCloseModal}
/>,
{ wrapper: AppWrapper },
);
};
const getFormElements = () => ({
formTag: screen.getByTitle('Mailbox creation form'),
inputFirstName: screen.getByLabelText(/First name/i),
inputLastName: screen.getByLabelText(/Last name/i),
inputLocalPart: screen.getByLabelText(/Email address prefix/i),
inputEmailAddress: screen.getByLabelText(/Secondary email address/i),
buttonCancel: screen.getByRole('button', { name: /Cancel/i, hidden: true }),
buttonSubmit: screen.getByRole('button', {
name: /Create the mailbox/i,
hidden: true,
}),
});
beforeEach(() => {
jest.clearAllMocks();
fetchMock.restore();
});
it('renders the modal with all fields and buttons', () => {
renderModalCreateMailbox();
const {
formTag,
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonCancel,
buttonSubmit,
} = getFormElements();
expect(formTag).toBeVisible();
expect(inputFirstName).toBeVisible();
expect(inputLastName).toBeVisible();
expect(inputLocalPart).toBeVisible();
expect(screen.getByText(`@${mockMailDomain.name}`)).toBeVisible();
expect(inputEmailAddress).toBeVisible();
expect(buttonCancel).toBeVisible();
expect(buttonSubmit).toBeVisible();
});
it('shows validation errors for empty fields', async () => {
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
// To bypass html form validation we need to fill and clear the fields
await userEvent.type(inputFirstName, 'John');
await userEvent.type(inputLastName, 'Doe');
await userEvent.type(inputLocalPart, 'john.doe');
await userEvent.type(inputEmailAddress, 'john.doe@mail.com');
await userEvent.clear(inputFirstName);
await userEvent.clear(inputLastName);
await userEvent.clear(inputLocalPart);
await userEvent.clear(inputEmailAddress);
await userEvent.click(buttonSubmit);
expect(screen.getByText(`@${mockMailDomain.name}`)).toBeVisible();
await waitFor(() => {
expect(
screen.getByText(/Please enter your first name/i),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText(/Please enter your last name/i),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText(/You must have minimum 1 character/i),
).toBeInTheDocument();
});
expect(fetchMock.lastUrl()).toBeFalsy();
expect(buttonSubmit).toBeDisabled();
});
it('calls the createMailbox API on form submission with valid data', async () => {
fetchMock.postOnce(apiUrl, {
status: 201,
});
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
await userEvent.type(inputFirstName, 'John');
await userEvent.type(inputLastName, 'Doe');
await userEvent.type(inputLocalPart, 'johndoe');
await userEvent.type(inputEmailAddress, 'john.doe@mail.com');
await userEvent.click(buttonSubmit);
await waitFor(() => {
expect(fetchMock.called(apiUrl)).toBeTruthy();
});
expect(fetchMock.lastCall(apiUrl)?.[1]?.body).toEqual(
JSON.stringify({
first_name: 'John',
last_name: 'Doe',
local_part: 'johndoe',
secondary_email: 'john.doe@mail.com',
}),
);
});
it('shows error message when mailbox prefix is already used', async () => {
fetchMock.postOnce(apiUrl, {
status: 400,
body: {
local_part: 'Mailbox with this Local_part and Domain already exists.',
},
});
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
await userEvent.type(inputFirstName, 'John');
await userEvent.type(inputLastName, 'Doe');
await userEvent.type(inputLocalPart, 'johndoe');
await userEvent.type(inputEmailAddress, 'john.doe@mail.com');
await userEvent.click(buttonSubmit);
await waitFor(() => {
expect(
screen.getByText(/This email prefix is already used./i),
).toBeInTheDocument();
});
});
it('closes the modal when cancel button is clicked', async () => {
renderModalCreateMailbox();
const { buttonCancel } = getFormElements();
await userEvent.click(buttonCancel);
expect(mockCloseModal).toHaveBeenCalled();
});
it('disables the submit button while the form is submitting', async () => {
fetchMock.postOnce(apiUrl, new Promise(() => {})); // Simulate pending state
renderModalCreateMailbox();
const {
inputFirstName,
inputLastName,
inputLocalPart,
inputEmailAddress,
buttonSubmit,
} = getFormElements();
await userEvent.type(inputFirstName, 'John');
await userEvent.type(inputLastName, 'Doe');
await userEvent.type(inputLocalPart, 'johndoe');
await userEvent.type(inputEmailAddress, 'john.doe@mail.com');
await userEvent.click(buttonSubmit);
await waitFor(() => {
expect(buttonSubmit).toBeDisabled();
});
});
});
@@ -46,7 +46,7 @@ export const PanelActions = () => {
<StyledLink href="/mail-domains/add/">
<Text
$margin="auto"
aria-label={t('Add your mail domain')}
aria-label={t('Add a mail domain')}
$theme="primary"
>
<IconAdd width={27} height={27} aria-hidden="true" />
@@ -1,8 +1,8 @@
import { usePathname } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, StyledLink } from '@/components';
import { Box, BoxButton } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { SVGComponent } from '@/types/components';
@@ -17,6 +17,7 @@ interface MenuItemProps {
const MenuItem = ({ Icon, label, href, alias }: MenuItemProps) => {
const { t } = useTranslation();
const router = useRouter();
const pathname = usePathname();
const { colorsTokens } = useCunninghamTheme();
const parentRef = useRef(null);
@@ -43,34 +44,28 @@ const MenuItem = ({ Icon, label, href, alias }: MenuItemProps) => {
};
return (
<StyledLink
href={href}
aria-current={isActive && 'page'}
ref={parentRef}
onMouseOver={() => setIsTooltipOpen(true)}
onMouseLeave={() => setIsTooltipOpen(false)}
style={{ display: 'block' }}
$css={`
&:focus-visible {
outline: #fff solid 2px;
}`}
>
<Box ref={parentRef}>
<Box
$margin="xtiny"
$padding="tiny"
as="li"
$justify="center"
$css={`
& > button { padding: 0};
transition: all 0.2s ease-in-out
`}
$css="transition: all 0.2s ease-in-out;"
$background={background}
$radius="10px"
>
<BoxButton
aria-current={isActive && 'page'}
onMouseOver={() => setIsTooltipOpen(true)}
onMouseLeave={() => setIsTooltipOpen(false)}
$css={`
padding: 0;
${isActive ? null : '&:focus-visible {outline: #fff solid 2px;}'}
`}
aria-label={t(`{{label}} button`, { label })}
$color={color}
as="span"
as="button"
onClick={() => router.push(href)}
>
<Icon
width="2.375rem"
@@ -81,15 +76,17 @@ const MenuItem = ({ Icon, label, href, alias }: MenuItemProps) => {
/>
</BoxButton>
</Box>
{isTooltipOpen && (
<Tooltip
parentRef={parentRef}
label={label}
backgroundColor={backgroundTooltip}
textColor={colorTooltip}
/>
)}
</StyledLink>
<Box as="span">
{isTooltipOpen && (
<Tooltip
parentRef={parentRef}
label={label}
backgroundColor={backgroundTooltip}
textColor={colorTooltip}
/>
)}
</Box>
</Box>
);
};
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -11,6 +10,7 @@ import { createGlobalStyle } from 'styled-components';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
import { Modal } from '@/components/Modal';
import { useCunninghamTheme } from '@/cunningham';
import { ChooseRole } from '@/features/teams/member-management';
import { Role, Team } from '@/features/teams/team-management';
@@ -347,4 +347,146 @@ describe('MemberGrid', () => {
expect(screen.queryByLabelText('arrow_drop_down')).not.toBeInTheDocument();
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
});
it('filters members based on the query', async () => {
const accesses: Access[] = [
{
id: '1',
role: Role.OWNER,
user: {
id: '11',
name: 'albert',
email: 'albert@test.com',
},
abilities: {} as any,
},
{
id: '2',
role: Role.MEMBER,
user: {
id: '22',
name: 'bob',
email: 'bob@test.com',
},
abilities: {} as any,
},
{
id: '3',
role: Role.ADMIN,
user: {
id: '33',
name: 'charlie',
email: 'charlie@test.com',
},
abilities: {} as any,
},
];
// Mocking initial load of all members
fetchMock.get(`end:/teams/123456/accesses/?page=1`, {
count: 3,
results: accesses,
});
// Mocking filtered results for 'bob'
fetchMock.get(`end:/teams/123456/accesses/?q=bob`, {
count: 1,
results: [
{
id: '2',
role: Role.MEMBER,
user: {
id: '22',
name: 'bob',
email: 'bob@test.com',
},
abilities: {} as any,
},
],
});
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
expect(await screen.findByText('albert')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
expect(screen.getByText('charlie')).toBeInTheDocument();
const searchInput = screen.getByLabelText('Filter member list');
await userEvent.type(searchInput, 'bob');
await waitFor(() => {
expect(screen.queryByText('albert')).not.toBeInTheDocument();
});
expect(screen.queryByText('charlie')).not.toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
});
it('displays "No members found" when filter returns no results', async () => {
const accesses: Access[] = [
{
id: '1',
role: Role.OWNER,
user: {
id: '11',
name: 'albert',
email: 'albert@test.com',
},
abilities: {} as any,
},
{
id: '2',
role: Role.MEMBER,
user: {
id: '22',
name: 'bob',
email: 'bob@test.com',
},
abilities: {} as any,
},
{
id: '3',
role: Role.ADMIN,
user: {
id: '33',
name: 'charlie',
email: 'charlie@test.com',
},
abilities: {} as any,
},
];
// Mocking initial load of all members
fetchMock.get(`end:/teams/123456/accesses/?page=1`, {
count: 3,
results: accesses,
});
// Mocking empty filtered results
fetchMock.get(`end:/teams/123456/accesses/?q=nonexistent`, {
count: 0,
results: [],
});
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
wrapper: AppWrapper,
});
expect(await screen.findByText('albert')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
expect(screen.getByText('charlie')).toBeInTheDocument();
const searchInput = screen.getByLabelText('Filter member list');
await userEvent.type(searchInput, 'nonexistent');
await waitFor(() => {
expect(screen.queryByText('albert')).not.toBeInTheDocument();
});
expect(screen.queryByText('bob')).not.toBeInTheDocument();
expect(screen.queryByText('charlie')).not.toBeInTheDocument();
expect(screen.getByText('This table is empty')).toBeInTheDocument();
});
});
@@ -0,0 +1,103 @@
import { APIError, fetchAPI } from '@/api';
import { getTeamAccesses } from '@/features/teams/member-management';
jest.mock('@/api', () => ({
fetchAPI: jest.fn(),
}));
describe('getTeamAccesses', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should construct the correct URL with only page', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({
count: 0,
next: null,
previous: null,
results: [],
}),
};
(fetchAPI as jest.Mock).mockResolvedValue(mockResponse);
await getTeamAccesses({
page: 1,
teamId: '123',
ordering: undefined,
query: undefined,
});
expect(fetchAPI).toHaveBeenCalledWith('teams/123/accesses/?page=1');
});
it('should construct the correct URL with only query', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({
count: 0,
next: null,
previous: null,
results: [],
}),
};
(fetchAPI as jest.Mock).mockResolvedValue(mockResponse);
await getTeamAccesses({
page: 1,
teamId: '123',
ordering: undefined,
query: 'patricia',
});
expect(fetchAPI).toHaveBeenCalledWith('teams/123/accesses/?q=patricia');
});
it('should construct the correct URL with ordering and query', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({
count: 0,
next: null,
previous: null,
results: [],
}),
};
(fetchAPI as jest.Mock).mockResolvedValue(mockResponse);
await getTeamAccesses({
page: 1,
teamId: '123',
ordering: 'user__name',
query: 'patricia',
});
expect(fetchAPI).toHaveBeenCalledWith(
'teams/123/accesses/?q=patricia&ordering=user__name',
);
});
it('should throw an APIError when the response is not ok', async () => {
const mockResponse = {
ok: false,
json: jest.fn().mockResolvedValue({}),
};
(fetchAPI as jest.Mock).mockResolvedValue(mockResponse);
await expect(
getTeamAccesses({
page: 1,
teamId: '123',
ordering: undefined,
query: undefined,
}),
).rejects.toThrow(APIError);
expect(fetchAPI).toHaveBeenCalledWith('teams/123/accesses/?page=1');
});
});
@@ -8,22 +8,42 @@ export type TeamAccessesAPIParams = {
page: number;
teamId: string;
ordering?: string;
query?: string;
};
type AccessesResponse = APIList<Access>;
/**
* @description Since there cannot be both a page and q params in query params at the same time, the queryString
* building order should not be changed.
* @param page
* @param teamId
* @param ordering
* @param q
*/
export const getTeamAccesses = async ({
page,
teamId,
ordering,
query,
}: TeamAccessesAPIParams): Promise<AccessesResponse> => {
let url = `teams/${teamId}/accesses/?page=${page}`;
const url = `teams/${teamId}/accesses/`;
if (ordering) {
url += '&ordering=' + ordering;
let queryString = '';
if (page) {
queryString = `?page=${page}`;
}
const response = await fetchAPI(url);
if (query) {
queryString = `?q=${query}`;
}
if (ordering) {
queryString += `&ordering=${ordering}`;
}
const response = await fetchAPI(`${url}${queryString}`);
if (!response.ok) {
throw new APIError(
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.7539 14.2549H14.9639L14.6839 13.9849C15.6639 12.8449 16.2539 11.3649 16.2539 9.75488C16.2539 6.16488 13.3439 3.25488 9.75391 3.25488C6.16391 3.25488 3.25391 6.16488 3.25391 9.75488C3.25391 13.3449 6.16391 16.2549 9.75391 16.2549C11.3639 16.2549 12.8439 15.6649 13.9839 14.6849L14.2539 14.9649V15.7549L19.2539 20.7449L20.7439 19.2549L15.7539 14.2549ZM9.75391 14.2549C7.26391 14.2549 5.25391 12.2449 5.25391 9.75488C5.25391 7.26488 7.26391 5.25488 9.75391 5.25488C12.2439 5.25488 14.2539 7.26488 14.2539 9.75488C14.2539 12.2449 12.2439 14.2549 9.75391 14.2549Z" fill="#000091"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

@@ -1,19 +1,22 @@
import {
Button,
DataGrid,
Input,
SortModel,
usePagination,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebounce } from '@/api';
import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Card, TextErrors } from '@/components';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { ModalAddMembers } from '@/features/teams/member-add';
import { Role, Team } from '@/features/teams/team-management';
import { useTeamAccesses } from '../api';
import IconMagnifyingGlass from '../assets/icon-magnifying-glass.svg';
import { PAGE_SIZE } from '../conf';
import { Access } from '../types';
@@ -43,8 +46,8 @@ const defaultOrderingMapping: Record<string, string> = {
*/
function formatSortModel(
sortModel: SortModelItem,
mapping = defaultOrderingMapping,
) {
mapping: Record<string, string> = defaultOrderingMapping,
): string {
const { field, sort } = sortModel;
const orderingField = mapping[field] || field;
return sort === 'desc' ? `-${orderingField}` : orderingField;
@@ -53,20 +56,24 @@ function formatSortModel(
export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
const [isModalMemberOpen, setIsModalMemberOpen] = useState(false);
const { t } = useTranslation();
const [queryValue, setQueryValue] = useState<string>('');
const { colorsTokens } = useCunninghamTheme();
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const [sortModel, setSortModel] = useState<SortModel>([]);
const [accesses, setAccesses] = useState<Access[]>([]);
const { page, pageSize, setPagesCount } = pagination;
const membersQuery = useDebounce(queryValue);
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const { data, isLoading, error } = useTeamAccesses({
teamId: team.id,
page,
ordering,
query: membersQuery,
});
useEffect(() => {
@@ -104,21 +111,46 @@ export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
return (
<>
{currentRole !== Role.MEMBER && (
<Box $margin={{ all: 'big', bottom: 'small' }} $align="flex-end">
<Button
aria-label={t('Add members to the team')}
style={{
width: 'fit-content',
minWidth: '8rem',
justifyContent: 'center',
}}
onClick={() => setIsModalMemberOpen(true)}
>
{t('Add a member')}
</Button>
<Box $margin={{ horizontal: 'big', bottom: 'small' }}>
<Text
$theme="primary"
$weight="bold"
$size="h3"
$margin={{ bottom: 'big' }}
>
{t('Group members')}
</Text>
<Box
$display="flex"
$direction="row"
$justify="space-between"
$align="center"
$gap="1rem"
$css={`
& > * {
flex: 0.23 0 auto;
}
`}
>
<Input
label={t('Filter member list')}
rightIcon={<IconMagnifyingGlass />}
onChange={(event) => setQueryValue(event.target.value)}
/>
{currentRole !== Role.MEMBER && (
<Button
aria-label={t('Add members to the team')}
style={{
minWidth: '8rem',
maxWidth: 'fit-content',
}}
onClick={() => setIsModalMemberOpen(true)}
>
{t('Add a member')}
</Button>
)}
</Box>
)}
</Box>
<Card
$padding={{ bottom: 'small' }}
$margin={{ all: 'big', top: 'none' }}
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -10,6 +9,7 @@ import { useRouter } from 'next/navigation';
import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { useCunninghamTheme } from '@/cunningham';
import { Role, Team } from '@/features/teams/team-management';
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -9,6 +8,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { Role } from '@/features/teams/team-management';
import { useUpdateTeamAccess } from '../api/useUpdateTeamAccess';
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group2.svg';
import { Box, Card, StyledLink, Text } from '@/components';
import { Box, Card, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useCreateTeam } from '../api';
@@ -14,6 +14,7 @@ import { InputTeamName } from './InputTeamName';
export const CardCreateTeam = () => {
const { t } = useTranslation();
const router = useRouter();
const {
mutate: createTeam,
isError,
@@ -24,6 +25,7 @@ export const CardCreateTeam = () => {
router.push(`/teams/${team.id}`);
},
});
const [teamName, setTeamName] = useState('');
const { colorsTokens } = useCunninghamTheme();
@@ -54,11 +56,10 @@ export const CardCreateTeam = () => {
/>
</Box>
<Box $justify="space-between" $direction="row" $align="center">
<StyledLink href="/">
<Button color="secondary" tabIndex={-1}>
{t('Cancel')}
</Button>
</StyledLink>
<Button color="secondary" onClick={() => router.push('/')}>
{t('Cancel')}
</Button>
<Button
onClick={() => createTeam(teamName)}
disabled={!teamName || isPending}
@@ -1,7 +1,9 @@
import { Input, Loader } from '@openfun/cunningham-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { parseAPIError } from '@/api/parseAPIError';
import { Box, TextErrors } from '@/components';
interface InputTeamNameProps {
@@ -21,6 +23,7 @@ export const InputTeamName = ({
label,
setTeamName,
}: InputTeamNameProps) => {
const { t } = useTranslation();
const [isInputError, setIsInputError] = useState(isError);
useEffect(() => {
@@ -29,6 +32,28 @@ export const InputTeamName = ({
}
}, [isError]);
const causes = error
? parseAPIError({
error,
errorParams: [
[
['Team with this Slug already exists.'],
t(
'This name is already used for another group. Please enter another one.',
),
undefined,
],
],
serverErrorParams: [
t(
'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',
),
undefined,
],
})
: undefined;
return (
<>
<Input
@@ -42,7 +67,7 @@ export const InputTeamName = ({
}}
state={isInputError ? 'error' : 'default'}
/>
{isError && error && <TextErrors causes={error.cause} />}
{isError && causes && <TextErrors causes={causes} />}
{isPending && (
<Box $align="center">
<Loader />
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -10,6 +9,7 @@ import { useRouter } from 'next/navigation';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useRemoveTeam } from '../api/useRemoveTeam';
@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -9,6 +8,7 @@ import { t } from 'i18next';
import { useState } from 'react';
import { Box, Text } from '@/components';
import { Modal } from '@/components/Modal';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useUpdateTeam } from '../api';
@@ -0,0 +1,149 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { CardCreateTeam } from '../CardCreateTeam';
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
describe('CardCreateTeam', () => {
const renderCardCreateTeam = () =>
render(<CardCreateTeam />, { wrapper: AppWrapper });
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
it('renders all the elements', () => {
renderCardCreateTeam();
expect(screen.getByLabelText('Create new team card')).toBeInTheDocument();
expect(screen.getByText('Create a new group')).toBeInTheDocument();
expect(screen.getByLabelText('Team name')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Create the team')).toBeInTheDocument();
});
it('handles input for team name and enables submit button', async () => {
const user = userEvent.setup();
renderCardCreateTeam();
const teamNameInput = screen.getByLabelText('Team name');
const createButton = screen.getByText('Create the team');
expect(createButton).toBeDisabled();
await user.type(teamNameInput, 'New Team');
expect(createButton).toBeEnabled();
});
it('creates a team successfully and redirects', async () => {
fetchMock.post('end:teams/', {
id: '270328ea-c2c0-4f74-a449-5cdc976dcdb6',
name: 'New Team',
});
const user = userEvent.setup();
renderCardCreateTeam();
const teamNameInput = screen.getByLabelText('Team name');
const createButton = screen.getByText('Create the team');
await user.type(teamNameInput, 'New Team');
await user.click(createButton);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(
'/teams/270328ea-c2c0-4f74-a449-5cdc976dcdb6',
);
});
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastCall()?.[0]).toContain('/teams/');
expect(fetchMock.lastCall()?.[1]?.body).toEqual(
JSON.stringify({ name: 'New Team' }),
);
});
it('displays an error message when team name already exists', async () => {
fetchMock.post('end:teams/', {
body: {
cause: ['Team with this Slug already exists.'],
},
status: 400,
});
const user = userEvent.setup();
renderCardCreateTeam();
const teamNameInput = screen.getByLabelText('Team name');
const createButton = screen.getByText('Create the team');
await user.type(teamNameInput, 'Existing Team');
await user.click(createButton);
await waitFor(() => {
expect(
screen.getByText(/This name is already used for another group/i),
).toBeInTheDocument();
});
});
it('handles server error gracefully', async () => {
fetchMock.post('end:/teams/', {
body: {},
status: 500,
});
const user = userEvent.setup();
renderCardCreateTeam();
const teamNameInput = screen.getByLabelText('Team name');
const createButton = screen.getByText('Create the team');
await user.type(teamNameInput, 'Server Error Team');
await user.click(createButton);
await waitFor(() => {
expect(
screen.getByText(
/Your request cannot be processed because the server is experiencing an error/i,
),
).toBeInTheDocument();
});
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastCall()?.[0]).toContain('/teams/');
expect(fetchMock.lastCall()?.[1]?.body).toEqual(
JSON.stringify({ name: 'Server Error Team' }),
);
});
it('disables create button when API request is pending', async () => {
// Never resolves
fetchMock.post('end:teams/', new Promise(() => {}));
const user = userEvent.setup();
renderCardCreateTeam();
const teamNameInput = screen.getByLabelText('Team name');
const createButton = screen.getByText('Create the team');
await user.type(teamNameInput, 'Pending Team');
await user.click(createButton);
expect(createButton).toBeDisabled();
});
});
@@ -11,17 +11,17 @@
"0 group to display.": "0 groupe à afficher.",
"Accessibility statement": "Déclaration d'accessibilité",
"Accessibility: non-compliant": "Accessibilité : non conforme",
"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 to group": "Ajouter au groupe",
"Add your mail domain": "Ajouter votre nom de domaine",
"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",
"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 remove this member from the {{team}} group?": "Êtes-vous sûr de vouloir supprimer ce membre du groupe {{team}}?",
"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",
@@ -58,11 +58,13 @@
"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}}",
"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",
"Freedom Equality Fraternity Logo": "Logo Liberté Égalité Fraternité",
"French Interministerial Directorate for Digital Affairs (DINUM), 20 avenue de Ségur 75007 Paris.": "Direction interministérielle des affaires numériques (DINUM), 20 avenue de Segur 75007 Paris.",
"Group details": "Détails du groupe",
"Group members": "Membres du groupe",
"Groups": "Groupes",
"If you are unable to access content or a service, you can contact the manager of La Régie (Suite Territoriale) \n to be directed to an accessible alternative or obtain the content in another form.": "Si vous narrivez pas à accéder à un contenu ou à un service, vous pouvez contacter le responsable de La Régie (Suite Territoriale) pour être orienté vers une alternative accessible ou obtenir le contenu sous une autre forme.",
"Illustration:": "Illustration :",
@@ -83,17 +85,19 @@
"List members card": "Carte liste des membres",
"Logout": "Se déconnecter",
"Mail Domains": "Domaines de messagerie",
"Mail domain addition form": "Formulaire d'ajout de domaine de messagerie",
"Mail domains panel": "Panel des domaines de messagerie",
"Mailbox created!": "Boîte mail créée !",
"Mailbox creation form": "Formulaire de création de boite mail",
"Mailboxes list": "Liste des boîtes mail",
"Marianne Logo": "Logo Marianne",
"Member": "Membre",
"Member icon": "Icône de membre",
"Member {{name}} added to the team": "Membre {{name}} ajouté au groupe",
"More info?": "Plus d'infos ?",
"My account": "Mon compte",
"Names": "Noms",
"New name...": "Nouveau nom...",
"No Username": "Aucun nom d'utilisateur",
"No domains exist.": "Aucun domaine existant.",
"No mail box was created with this mail domain.": "Aucune boîte mail n'a été créée avec ce nom de domaine.",
"Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.",
@@ -144,6 +148,7 @@
"This domain name is deactivated. No new mailboxes can be created.": "Ce nom de domaine est désactivé. Aucune nouvelle boîte mail ne peut être créée.",
"This email prefix is already used.": "Ce préfixe d'email est déjà utilisé.",
"This mail domain is already used. Please, choose another one.": "Ce domaine de messagerie est déjà utilisé. Veuillez en choisir un autre.",
"This name is already used for another group. Please enter another one.": "Un autre groupe utilise déjà ce nom. Veuillez en saisir un autre.",
"This procedure is to be used in the following case: you have reported to the website \n manager an accessibility defect which prevents you from accessing content or one of the \n portal's services and you have not obtained a satisfactory response.": "Cette procédure est à utiliser dans le cas suivant : vous avez signalé au responsable du site internet un défaut daccessibilité qui vous empêche daccéder à un contenu ou à un des services du portail et vous navez pas obtenu de réponse satisfaisante.",
"This site does not display a cookie consent banner, why?": "Ce site n'affiche pas de bannière de consentement des cookies, pourquoi?",
"This site places a small text file (a \"cookie\") on your computer when you visit it.": "Ce site place un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le visitez.",
@@ -164,6 +169,8 @@
"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",
"Your request to create a mailbox cannot be completed due to incorrect settings on our server. Please contact our support team to resolve the problem: suiteterritoriale@anct.gouv.fr": "Votre demande de création de boîte mail ne peut pas être complétée en raison de paramètres incorrects sur notre serveur. Veuillez contacter notre équipe support pour résoudre le problème : suiteterritoriale@anct.gouv.fr",
"[disabled]": "[désactivé]",
"[enabled]": "[actif]",
"[failed]": "[erroné]",
@@ -2,13 +2,13 @@ import React, { ReactElement } from 'react';
import { Box } from '@/components';
import { MailDomainsLayout } from '@/features/mail-domains';
import { ModalCreateMailDomain } from '@/features/mail-domains/components/ModalAddMailDomain';
import { ModalAddMailDomain } from '@/features/mail-domains/components/ModalAddMailDomain';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
return (
<Box $padding="large" $height="inherit">
<ModalCreateMailDomain />
<ModalAddMailDomain />
</Box>
);
};
@@ -20,7 +20,7 @@ const Page: NextPageWithLayout = () => {
return (
<Box $align="center" $justify="center" $height="inherit">
<StyledButton onClick={() => void router.push('/mail-domains/add')}>
{t('Add your mail domain')}
{t('Add a mail domain')}
</StyledButton>
</Box>
);
@@ -56,7 +56,7 @@ test.describe('Config', () => {
await expect(
page.getByRole('button', {
name: 'Add your mail domain',
name: 'Add a mail domain',
}),
).toBeVisible();
});
@@ -8,7 +8,7 @@ test.beforeEach(async ({ page, browserName }) => {
});
test.describe('Header', () => {
test('checks all the elements are visible', async ({ page }) => {
test('checks all the elements are visible', async ({ page, browserName }) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Marianne Logo')).toBeVisible();
@@ -37,13 +37,15 @@ test.describe('Header', () => {
).toBeVisible();
await expect(header.getByRole('combobox').getByText('EN')).toBeVisible();
await expect(header.getByText('My account')).toBeVisible();
await expect(
header.getByText(new RegExp(`E2E ${browserName}`, 'i')),
).toBeVisible();
});
test('checks logout button', async ({ page }) => {
test('checks logout button', async ({ page, browserName }) => {
await page
.getByRole('button', {
name: 'My account',
name: new RegExp(`E2E ${browserName}`, 'i'),
})
.click();
@@ -65,21 +65,10 @@ test.describe('Keyboard navigation', () => {
)
.all();
expect(focusableElements.length).toEqual(20);
expect(focusableElements.length).toEqual(19);
for (let i = 0; i < focusableElements.length; i++) {
await page.keyboard.press('Tab');
// check language picker language option navigation. 4th element is inner language picker arrow button
// eslint-disable-next-line playwright/no-conditional-in-test
if (i === 3) {
await page.keyboard.press('Enter');
// eslint-disable-next-line playwright/no-conditional-expect
await expect(focusableElements[i]).toBeFocused();
continue;
}
await expect(focusableElements[i]).toBeFocused();
}
});
@@ -314,157 +314,4 @@ test.describe('Mail domain create mailbox', () => {
page.getByRole('button', { name: 'Create a mailbox' }),
).not.toBeInViewport();
});
test('checks client invalidation messages are displayed and no mailbox creation request is sent when fields are not properly filled', async ({
page,
}) => {
let isCreateMailboxRequestSent = false;
page.on(
'request',
(request) =>
(isCreateMailboxRequestSent =
request.url().includes('/mail-domains/domainfr/mailboxes/') &&
request.method() === 'POST'),
);
void interceptCommonApiRequests(page);
await navigateToMailboxCreationFormForMailDomainFr(page);
const inputFirstName = page.getByLabel('First name');
const inputLastName = page.getByLabel('Last name');
const inputLocalPart = page.getByLabel('Email address prefix');
const inputSecondaryEmailAddress = page.getByLabel(
'Secondary email address',
);
const textInvalidLocalPart = page.getByText(
'It must not contain spaces, accents or special characters (except "." or "-"). E.g.: jean.dupont',
);
const textInvalidSecondaryEmailAddress = page.getByText(
'Please enter a valid email address.\nE.g. : jean.dupont@mail.fr',
);
await inputFirstName.fill(' ');
await inputFirstName.clear();
await expect(page.getByText('Please enter your first name')).toBeVisible();
await inputLastName.fill(' ');
await inputLastName.clear();
await expect(page.getByText('Please enter your last name')).toBeVisible();
await inputLocalPart.fill('wrong@');
await expect(textInvalidLocalPart).toBeVisible();
await inputSecondaryEmailAddress.fill('uncomplete@mail');
await expect(textInvalidSecondaryEmailAddress).toBeVisible();
await inputLocalPart.clear();
await inputLocalPart.fill('wrong ');
await expect(textInvalidLocalPart).toBeVisible();
await inputLocalPart.clear();
await expect(
page.getByText('You must have minimum 1 character'),
).toBeVisible();
await page.getByRole('button', { name: 'Create the mailbox' }).click();
expect(isCreateMailboxRequestSent).toBeFalsy();
});
test('checks field invalidation messages are displayed when sending already existing local_part data in mail domain to api', async ({
page,
}) => {
const interceptRequests = (page: Page) => {
void interceptCommonApiRequests(page);
void page.route(
'**/api/v1.0/mail-domains/domainfr/mailboxes/',
(route) => {
if (route.request().method() === 'POST') {
void route.fulfill({
status: 400,
json: {
local_part: [
'Mailbox with this Local_part and Domain already exists.',
],
},
});
}
},
{ times: 1 },
);
};
void interceptRequests(page);
await navigateToMailboxCreationFormForMailDomainFr(page);
const inputFirstName = page.getByLabel('First name');
const inputLastName = page.getByLabel('Last name');
const inputLocalPart = page.getByLabel('Email address prefix');
const inputSecondaryEmailAddress = page.getByLabel(
'Secondary email address',
);
const submitButton = page.getByRole('button', {
name: 'Create the mailbox',
});
const textAlreadyUsedLocalPart = page.getByText(
'This email prefix is already used.',
);
await inputFirstName.fill('John');
await inputLastName.fill('Doe');
await inputLocalPart.fill('john.already');
await inputSecondaryEmailAddress.fill('john.already@mail.com');
await submitButton.click();
await expect(textAlreadyUsedLocalPart).toBeVisible();
});
test('checks unknown api error causes are displayed above form when they are not related with invalid field', async ({
page,
}) => {
const interceptRequests = async (page: Page) => {
void interceptCommonApiRequests(page);
await page.route(
'**/api/v1.0/mail-domains/domainfr/mailboxes/',
async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 500,
json: {
unknown_error: ['Unknown error from server'],
},
});
}
},
{ times: 1 },
);
};
void interceptRequests(page);
await navigateToMailboxCreationFormForMailDomainFr(page);
const inputFirstName = page.getByLabel('First name');
const inputLastName = page.getByLabel('Last name');
const inputLocalPart = page.getByLabel('Email address prefix');
const inputSecondaryEmailAddress = page.getByLabel(
'Secondary email address',
);
await inputFirstName.fill('John');
await inputLastName.fill('Doe');
await inputLocalPart.fill('john.doe');
await inputSecondaryEmailAddress.fill('john.do@mail.fr');
await page.getByRole('button', { name: 'Create the mailbox' }).click();
await expect(page.getByText('Unknown error from server')).toBeVisible();
});
});
@@ -1,7 +1,32 @@
import { expect, test } from '@playwright/test';
import { Page, expect, test } from '@playwright/test';
import { keyCloakSignIn, randomName } from './common';
const getElements = (page: Page) => {
const panel = page.getByLabel('Mail domains panel').first();
const linkIndexPageAddDomain = page.getByRole('link', {
name: 'Add a mail domain',
});
const form = page.locator('form');
const inputName = form.getByLabel('Domain name');
const buttonSubmit = page.getByRole('button', {
name: 'Add the domain',
});
const buttonCancel = page.getByRole('button', {
name: 'Cancel',
});
return {
panel,
linkIndexPageAddDomain,
form,
inputName,
buttonCancel,
buttonSubmit,
};
};
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
@@ -9,27 +34,23 @@ test.beforeEach(async ({ page, browserName }) => {
test.describe('Add Mail Domains', () => {
test('checks all the elements are visible', async ({ page }) => {
await page.goto('/mail-domains');
await page.goto('/mail-domains/');
const buttonFromHomePage = page.getByRole('button', {
name: 'Add your mail domain',
});
const { linkIndexPageAddDomain, inputName } = getElements(page);
await expect(buttonFromHomePage).toBeVisible();
await buttonFromHomePage.click();
await expect(linkIndexPageAddDomain).toBeVisible();
await linkIndexPageAddDomain.click();
await expect(buttonFromHomePage).toBeHidden();
await expect(linkIndexPageAddDomain).toBeHidden();
await expect(
page.getByRole('heading', {
name: 'Add your mail domain',
name: 'Add a mail domain',
level: 3,
}),
).toBeVisible();
const form = page.locator('form');
await expect(form.getByLabel('Domain name')).toBeVisible();
await expect(inputName).toBeVisible();
await expect(page.getByText('Example: saint-laurent.fr')).toBeVisible();
@@ -46,38 +67,25 @@ test.describe('Add Mail Domains', () => {
});
test('checks the cancel button interaction', async ({ page }) => {
await page.goto('/mail-domains');
await page.goto('/mail-domains/');
const buttonFromHomePage = page.getByRole('button', {
name: 'Add your mail domain',
});
await buttonFromHomePage.click();
const { linkIndexPageAddDomain, buttonCancel } = getElements(page);
await expect(buttonFromHomePage).toBeHidden();
await linkIndexPageAddDomain.click();
await buttonCancel.click();
await page
.getByRole('button', {
name: 'Cancel',
})
.click();
await expect(buttonCancel).toBeHidden();
await expect(buttonFromHomePage).toBeVisible();
await expect(linkIndexPageAddDomain).toBeVisible();
});
test('checks form invalid status', async ({ page }) => {
await page.goto('/mail-domains');
await page.goto('/mail-domains/');
const buttonFromHomePage = page.getByRole('button', {
name: 'Add your mail domain',
});
await buttonFromHomePage.click();
const { linkIndexPageAddDomain, inputName, buttonSubmit } =
getElements(page);
const form = page.locator('form');
const inputName = form.getByLabel('Domain name');
const buttonSubmit = page.getByRole('button', {
name: 'Add the domain',
});
await linkIndexPageAddDomain.click();
await expect(inputName).toBeVisible();
await expect(page.getByText('Example: saint-laurent.fr')).toBeVisible();
@@ -105,18 +113,17 @@ test.describe('Add Mail Domains', () => {
const mailDomainName = randomName('versailles.fr', browserName, 1)[0];
const mailDomainSlug = mailDomainName.replace('.', '');
await page.goto('/mail-domains');
await page.goto('/mail-domains/');
const panel = page.getByLabel('Mail domains panel').first();
const { linkIndexPageAddDomain, inputName, buttonSubmit } =
getElements(page);
await panel.getByRole('link', { name: 'Add your mail domain' }).click();
await linkIndexPageAddDomain.click();
const form = page.locator('form');
await inputName.fill(mailDomainName);
await buttonSubmit.click();
await form.getByLabel('Domain name').fill(mailDomainName);
await page.getByRole('button', { name: 'Add the domain' }).click();
await expect(page).toHaveURL(`/mail-domains\/${mailDomainSlug}/`);
await expect(page).toHaveURL(`/mail-domains/${mailDomainSlug}/`);
await expect(
page.getByRole('heading', {
@@ -125,43 +132,6 @@ test.describe('Add Mail Domains', () => {
).toBeVisible();
});
test('checks error when duplicate mail domain', async ({
page,
browserName,
}) => {
await page.goto('/mail-domains');
const panel = page.getByLabel('Mail domains panel').first();
const additionLink = panel.getByRole('link', {
name: 'Add your mail domain',
});
const form = page.locator('form');
const inputName = form.getByLabel('Domain name');
const submitButton = page.getByRole('button', {
name: 'Add the domain',
});
const mailDomainName = randomName('duplicate.fr', browserName, 1)[0];
const mailDomainSlug = mailDomainName.replace('.', '');
await additionLink.click();
await inputName.fill(mailDomainName);
await submitButton.click();
await expect(page).toHaveURL(`/mail-domains\/${mailDomainSlug}\/`);
await additionLink.click();
await inputName.fill(mailDomainName);
await submitButton.click();
await expect(
page.getByText(
'This mail domain is already used. Please, choose another one.',
),
).toBeVisible();
});
test('checks 404 on mail-domains/[slug] page', async ({ page }) => {
await page.goto('/mail-domains/unknown-domain');
@@ -16,6 +16,9 @@ test.describe('Team', () => {
await createTeam(page, 'team-top-box', browserName, 1)
).shift();
await expect(page.getByText('Group members')).toBeVisible();
await expect(page.getByLabel('Filter member list')).toBeVisible();
await expect(
page.getByRole('heading', {
name: teamName,
@@ -54,4 +57,19 @@ test.describe('Team', () => {
await expect(page.getByText('The team has been updated.')).toBeVisible();
await expect(page.getByText(`Group details`)).toBeVisible();
});
test('sorts group members by search term', async ({
page,
browserName,
request,
}) => {
await createTeam(page, 'team-to-sort', browserName, 1);
await page.getByLabel(`Open the team options`).click();
await page.getByLabel('Filter member list').fill('term-to-search');
const response = await request.get('teams/?page=1&q=term-to-search');
expect(response.ok()).toBeTruthy();
});
});
@@ -108,7 +108,9 @@ test.describe('Teams Create', () => {
await page.getByRole('button', { name: 'Create the team' }).click();
await expect(
page.getByText('Team with this Slug already exists.'),
page.getByText(
'This name is already used for another group. Please enter another one.',
),
).toBeVisible();
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.0.2",
"version": "1.2.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -9,7 +9,7 @@
"test:ui": "yarn test --ui"
},
"devDependencies": {
"@playwright/test": "1.46.1",
"@playwright/test": "1.47.2",
"@types/node": "*",
"eslint-config-people": "*",
"typescript": "*"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "people",
"version": "1.0.2",
"version": "1.2.0",
"private": true,
"workspaces": {
"packages": [
@@ -24,10 +24,10 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@types/node": "20.16.2",
"@types/node": "20.16.10",
"@types/react-dom": "18.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"typescript": "5.5.4"
"typescript": "5.6.2"
}
}
@@ -1,24 +1,24 @@
{
"name": "eslint-config-people",
"version": "1.0.2",
"version": "1.2.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "14.2.7",
"@tanstack/eslint-plugin-query": "5.52.3",
"@typescript-eslint/eslint-plugin": "8.3.0",
"@typescript-eslint/parser": "8.3.0",
"@next/eslint-plugin-next": "14.2.13",
"@tanstack/eslint-plugin-query": "5.58.1",
"@typescript-eslint/eslint-plugin": "8.7.0",
"@typescript-eslint/parser": "8.7.0",
"eslint": "8.57.0",
"eslint-config-next": "14.2.7",
"eslint-config-next": "14.2.13",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "28.8.1",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-react": "7.37.0",
"eslint-plugin-testing-library": "6.3.0"
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.0.2",
"version": "1.2.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:desk",
@@ -11,10 +11,10 @@
"test": "jest"
},
"dependencies": {
"@types/jest": "29.5.12",
"@types/jest": "29.5.13",
"@types/node": "*",
"eslint-config-people": "*",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-import": "2.30.0",
"i18next-parser": "9.0.2",
"jest": "29.7.0",
"ts-jest": "29.2.5",
+424 -263
View File
@@ -1001,7 +1001,7 @@
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.24.5", "@babel/runtime@^7.24.8", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.24.5", "@babel/runtime@^7.25.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
version "7.25.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2"
integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==
@@ -1760,62 +1760,62 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@next/env@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.7.tgz#40fcd6ccdd53fd7e6788a0604f39032c84bea112"
integrity sha512-OTx9y6I3xE/eih+qtthppwLytmpJVPM5PPoJxChFsbjIEFXIayG0h/xLzefHGJviAa3Q5+Fd+9uYojKkHDKxoQ==
"@next/env@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.13.tgz#ba341ba9eb70db428fc1c754f49c3c516f7bab47"
integrity sha512-s3lh6K8cbW1h5Nga7NNeXrbe0+2jIIYK9YaA9T7IufDWnZpozdFUp6Hf0d5rNWUKu4fEuSX2rCKlGjCrtylfDw==
"@next/eslint-plugin-next@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.7.tgz#22154d5833124fdd439d158b91a9cf87ea53f5fa"
integrity sha512-+7xh142AdhZGjY9/L0iFo7mqRBMJHe+q+uOL+hto1Lfo9DeWCGcR6no4StlFbVSVcA6fQLKEX6y6qhMsSKbgNQ==
"@next/eslint-plugin-next@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.13.tgz#af433f20273d67a6c9c4f840b799977fc101fe52"
integrity sha512-z8Mk0VljxhIzsSiZUSdt3wp+t2lKd+jk5a9Jsvh3zDGkItgDMfjv/ZbET6HsxEl/fSihVoHGsXV6VLyDH0lfTQ==
dependencies:
glob "10.3.10"
"@next/swc-darwin-arm64@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.7.tgz#6cd39ba5d5f43705de44e389d4b4f5d2df391927"
integrity sha512-UhZGcOyI9LE/tZL3h9rs/2wMZaaJKwnpAyegUVDGZqwsla6hMfeSj9ssBWQS9yA4UXun3pPhrFLVnw5KXZs3vw==
"@next/swc-darwin-arm64@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.13.tgz#76f08d78360c4d27d444df7f35a56f59a48f4808"
integrity sha512-IkAmQEa2Htq+wHACBxOsslt+jMoV3msvxCn0WFSfJSkv/scy+i/EukBKNad36grRxywaXUYJc9mxEGkeIs8Bzg==
"@next/swc-darwin-x64@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.7.tgz#a1d191a293443cf8df9451b8f13a348caa718cb7"
integrity sha512-ys2cUgZYRc+CbyDeLAaAdZgS7N1Kpyy+wo0b/gAj+SeOeaj0Lw/q+G1hp+DuDiDAVyxLBCJXEY/AkhDmtihUTA==
"@next/swc-darwin-x64@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.13.tgz#1d4821d54bb01dacc6a6c32408f8468a4f4af269"
integrity sha512-Dv1RBGs2TTjkwEnFMVL5XIfJEavnLqqwYSD6LXgTPdEy/u6FlSrLBSSfe1pcfqhFEXRAgVL3Wpjibe5wXJzWog==
"@next/swc-linux-arm64-gnu@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.7.tgz#9da3f993b3754b900fe7b469de51898fc51112f2"
integrity sha512-2xoWtE13sUJ3qrC1lwE/HjbDPm+kBQYFkkiVECJWctRASAHQ+NwjMzgrfqqMYHfMxFb5Wws3w9PqzZJqKFdWcQ==
"@next/swc-linux-arm64-gnu@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.13.tgz#79d9af8d3408df9990c8911889eca1ca6a308f19"
integrity sha512-yB1tYEFFqo4ZNWkwrJultbsw7NPAAxlPXURXioRl9SdW6aIefOLS+0TEsKrWBtbJ9moTDgU3HRILL6QBQnMevg==
"@next/swc-linux-arm64-musl@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.7.tgz#f75662bdedd2d91ad7e05778274fa17659f1f02f"
integrity sha512-+zJ1gJdl35BSAGpkCbfyiY6iRTaPrt3KTl4SF/B1NyELkqqnrNX6cp4IjjjxKpd64/7enI0kf6b9O1Uf3cL0pw==
"@next/swc-linux-arm64-musl@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.13.tgz#b13180645865b120591db2f1e831743ebc02ab36"
integrity sha512-v5jZ/FV/eHGoWhMKYrsAweQ7CWb8xsWGM/8m1mwwZQ/sutJjoFaXchwK4pX8NqwImILEvQmZWyb8pPTcP7htWg==
"@next/swc-linux-x64-gnu@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.7.tgz#3c6c5b551a5af4fc8178bd5733c8063266034e79"
integrity sha512-m6EBqrskeMUzykBrv0fDX/28lWIBGhMzOYaStp0ihkjzIYJiKUOzVYD1gULHc8XDf5EMSqoH/0/TRAgXqpQwmw==
"@next/swc-linux-x64-gnu@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.13.tgz#8cb8480dfeee512648e4e08c2095aac0461b876f"
integrity sha512-aVc7m4YL7ViiRv7SOXK3RplXzOEe/qQzRA5R2vpXboHABs3w8vtFslGTz+5tKiQzWUmTmBNVW0UQdhkKRORmGA==
"@next/swc-linux-x64-musl@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.7.tgz#16f92f00263d1fce91ae80e5f230eb1feea484e4"
integrity sha512-gUu0viOMvMlzFRz1r1eQ7Ql4OE+hPOmA7smfZAhn8vC4+0swMZaZxa9CSIozTYavi+bJNDZ3tgiSdMjmMzRJlQ==
"@next/swc-linux-x64-musl@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.13.tgz#df5ca922fa1e1ee81b15a06a2d3d3ace0efd2bd7"
integrity sha512-4wWY7/OsSaJOOKvMsu1Teylku7vKyTuocvDLTZQq0TYv9OjiYYWt63PiE1nTuZnqQ4RPvME7Xai+9enoiN0Wrg==
"@next/swc-win32-arm64-msvc@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.7.tgz#1224cb8a04cd9caad785a2187df9e85b49414a42"
integrity sha512-PGbONHIVIuzWlYmLvuFKcj+8jXnLbx4WrlESYlVnEzDsa3+Q2hI1YHoXaSmbq0k4ZwZ7J6sWNV4UZfx1OeOlbQ==
"@next/swc-win32-arm64-msvc@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.13.tgz#8a7db6e71f526212587975f743b28e4d1cb829d1"
integrity sha512-uP1XkqCqV2NVH9+g2sC7qIw+w2tRbcMiXFEbMihkQ8B1+V6m28sshBwAB0SDmOe0u44ne1vFU66+gx/28RsBVQ==
"@next/swc-win32-ia32-msvc@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.7.tgz#9494aaf9cc50ddef600f8c1b2ed0f216b19f9294"
integrity sha512-BiSY5umlx9ed5RQDoHcdbuKTUkuFORDqzYKPHlLeS+STUWQKWziVOn3Ic41LuTBvqE0TRJPKpio9GSIblNR+0w==
"@next/swc-win32-ia32-msvc@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.13.tgz#6aa664f36f2d70c5ae6ffcbbc56784d33f24522d"
integrity sha512-V26ezyjPqQpDBV4lcWIh8B/QICQ4v+M5Bo9ykLN+sqeKKBxJVDpEc6biDVyluTXTC40f5IqCU0ttth7Es2ZuMw==
"@next/swc-win32-x64-msvc@14.2.7":
version "14.2.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.7.tgz#75e1d90758cb10a547e1cdfb878871da28123682"
integrity sha512-pxsI23gKWRt/SPHFkDEsP+w+Nd7gK37Hpv0ngc5HpWy2e7cKx9zR/+Q2ptAUqICNTecAaGWvmhway7pj/JLEWA==
"@next/swc-win32-x64-msvc@14.2.13":
version "14.2.13"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.13.tgz#5a920eea82a58affa6146192586716cec6c87fed"
integrity sha512-WwzOEAFBGhlDHE5Z73mNU8CO8mqMNLqaG+AO9ETmzdCQlJhVtWZnOl2+rqgVQS+YHunjOWptdFmNfbpwcUuEsw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -1890,12 +1890,12 @@
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==
"@playwright/test@1.46.1":
version "1.46.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.46.1.tgz#a8dfdcd623c4c23bb1b7ea588058aad41055c188"
integrity sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==
"@playwright/test@1.47.2":
version "1.47.2"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.47.2.tgz#dbe7051336bfc5cc599954214f9111181dbc7475"
integrity sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==
dependencies:
playwright "1.46.1"
playwright "1.47.2"
"@react-aria/breadcrumbs@^3.5.16":
version "3.5.16"
@@ -3031,6 +3031,11 @@
"@react-types/overlays" "^3.8.9"
"@react-types/shared" "^3.24.1"
"@rtsao/scc@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
"@rushstack/eslint-patch@^1.3.3":
version "1.10.4"
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1"
@@ -3181,36 +3186,36 @@
dependencies:
tslib "^2.4.0"
"@tanstack/eslint-plugin-query@5.52.3":
version "5.52.3"
resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.52.3.tgz#40c14090f5b17665e24571f35afce18996d20016"
integrity sha512-FAApj04uO4OkWsx+ZS9TpEgv8YyacLN/hWlAfqSZGHGcPSnUVxuE+tKyuHhfwN0ebBazxL8+lJ19OtLbm+dE8w==
"@tanstack/eslint-plugin-query@5.58.1":
version "5.58.1"
resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.58.1.tgz#92893d6d0f895de1cafec5b21386154bcaf66d48"
integrity sha512-hJR3N5ilK60gCgDWr7pWHV/vDiDVczT95F8AGIcg1gf9117aLPK+LDu+xP2JuEWpWKpsQ6OpWdVMim9kKlMybw==
dependencies:
"@typescript-eslint/utils" "8.0.0-alpha.30"
"@typescript-eslint/utils" "^8.3.0"
"@tanstack/query-core@5.52.3":
version "5.52.3"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.52.3.tgz#4d66b3e7a6d77513cb4d3cb661b2c03009103f5a"
integrity sha512-+Gh7lXn+eoAsarvvnndgqBeJ5lOjup8qgQnrTsFuhNTEAo0H934DxEPro4s3TlmvITfDTJ3UDCy7kY8Azm0qsA==
"@tanstack/query-core@5.56.2":
version "5.56.2"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.56.2.tgz#2def2fb0290cd2836bbb08afb0c175595bb8109b"
integrity sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q==
"@tanstack/query-devtools@5.52.3":
version "5.52.3"
resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.52.3.tgz#33c0251d5618421601c5473c759054bf31dbe4b8"
integrity sha512-oGX9qJuNpr4vOQyeksqHr+FgLQGs5UooK87R1wTtcsUUdrRKGSgs3cBllZMtWBJxg+yVvg0TlHNGYLMjvqX3GA==
"@tanstack/query-devtools@5.58.0":
version "5.58.0"
resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.58.0.tgz#5c68ce90562e154004de4372bc0a28fcd94cdf31"
integrity sha512-iFdQEFXaYYxqgrv63ots+65FGI+tNp5ZS5PdMU1DWisxk3fez5HG3FyVlbUva+RdYS5hSLbxZ9aw3yEs97GNTw==
"@tanstack/react-query-devtools@5.52.3":
version "5.52.3"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.52.3.tgz#efde61cb375333b8b0fd0e0eca474f8655005d7b"
integrity sha512-KU5OaCVOTcZF7OknXRcXXF2KIKjARe/xOa/J60Gftyic6krHQHRHjoFIbiOEH/gOQ1oV/uI59NbdQNbHim0z7w==
"@tanstack/react-query-devtools@5.58.0":
version "5.58.0"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.58.0.tgz#0d541cf8f755402553da540bcf7a1f802f49a8d5"
integrity sha512-qF0xCyBeVuNLygTO1sAl1X4Gv52w52SeaDdbjYQmtTOooUJ3aAVlBEtiRJFfJblWQ9p/UQG8NIcC/65RjX8Jkw==
dependencies:
"@tanstack/query-devtools" "5.52.3"
"@tanstack/query-devtools" "5.58.0"
"@tanstack/react-query@5.52.3":
version "5.52.3"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.52.3.tgz#eb36592e6ff0869248ccfd674e626d458e1225c1"
integrity sha512-1K7l2hkqlWuh5SdaTYPSwMmHJF5dDk5INK+EtiEwUZW4+usWTXZx7QeHuk078oSzTzaVkEFyT3VquK7F0hYkUw==
"@tanstack/react-query@5.56.2":
version "5.56.2"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.56.2.tgz#3a0241b9d010910905382f5e99160997b8795f91"
integrity sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg==
dependencies:
"@tanstack/query-core" "5.52.3"
"@tanstack/query-core" "5.56.2"
"@tanstack/react-table@8.20.1":
version "8.20.1"
@@ -3357,10 +3362,10 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@29.5.12":
version "29.5.12"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544"
integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==
"@types/jest@29.5.13":
version "29.5.13"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.13.tgz#8bc571659f401e6a719a7bf0dbcb8b78c71a8adc"
integrity sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==
dependencies:
expect "^29.0.0"
pretty-format "^29.0.0"
@@ -3384,7 +3389,12 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/lodash@4.17.7", "@types/lodash@^4.14.168":
"@types/lodash@4.17.9":
version "4.17.9"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.9.tgz#0dc4902c229f6b8e2ac5456522104d7b1a230290"
integrity sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==
"@types/lodash@^4.14.168":
version "4.17.7"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612"
integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==
@@ -3399,10 +3409,10 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
"@types/node@*", "@types/node@20.16.2":
version "20.16.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.16.2.tgz#9e388f503a5af306e8c63319334887390966a11e"
integrity sha512-91s/n4qUPV/wg8eE9KHYW1kouTfDk2FPGjXbBMfRWP/2vg1rCXNQL1OCabwGs0XSdukuK+MwCDXE30QpSeMUhQ==
"@types/node@*", "@types/node@20.16.10":
version "20.16.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.16.10.tgz#0cc3fdd3daf114a4776f54ba19726a01c907ef71"
integrity sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==
dependencies:
undici-types "~6.19.2"
@@ -3437,7 +3447,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@18.3.4":
"@types/react@*":
version "18.3.4"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.4.tgz#dfdd534a1d081307144c00e325c06e00312c93a3"
integrity sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==
@@ -3445,6 +3455,14 @@
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/react@18.3.10":
version "18.3.10"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.10.tgz#6edc26dc22ff8c9c226d3c7bf8357b013c842219"
integrity sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==
dependencies:
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/semver@^7.3.12":
version "7.5.8"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
@@ -3482,41 +3500,56 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz#726627fad16d41d20539637efee8c2329fe6be32"
integrity sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==
"@typescript-eslint/eslint-plugin@8.7.0":
version "8.7.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz#d0070f206daad26253bf00ca5b80f9b54f9e2dd0"
integrity sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.3.0"
"@typescript-eslint/type-utils" "8.3.0"
"@typescript-eslint/utils" "8.3.0"
"@typescript-eslint/visitor-keys" "8.3.0"
"@typescript-eslint/scope-manager" "8.7.0"
"@typescript-eslint/type-utils" "8.7.0"
"@typescript-eslint/utils" "8.7.0"
"@typescript-eslint/visitor-keys" "8.7.0"
graphemer "^1.4.0"
ignore "^5.3.1"
natural-compare "^1.4.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/parser@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.3.0.tgz#3c72c32bc909cb91ce3569e7d11d729ad84deafa"
integrity sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==
"@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz#20049754ff9f6d3a09bf240297f029ce04290999"
integrity sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==
dependencies:
"@typescript-eslint/scope-manager" "8.3.0"
"@typescript-eslint/types" "8.3.0"
"@typescript-eslint/typescript-estree" "8.3.0"
"@typescript-eslint/visitor-keys" "8.3.0"
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.6.0"
"@typescript-eslint/type-utils" "8.6.0"
"@typescript-eslint/utils" "8.6.0"
"@typescript-eslint/visitor-keys" "8.6.0"
graphemer "^1.4.0"
ignore "^5.3.1"
natural-compare "^1.4.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/parser@8.7.0":
version "8.7.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.7.0.tgz#a567b0890d13db72c7348e1d88442ea8ab4e9173"
integrity sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==
dependencies:
"@typescript-eslint/scope-manager" "8.7.0"
"@typescript-eslint/types" "8.7.0"
"@typescript-eslint/typescript-estree" "8.7.0"
"@typescript-eslint/visitor-keys" "8.7.0"
debug "^4.3.4"
"@typescript-eslint/parser@^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.2.0.tgz#44356312aea8852a3a82deebdacd52ba614ec07a"
integrity sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==
"@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.6.0.tgz#02e092b9dc8b4e319172af620d0d39b337d948f6"
integrity sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==
dependencies:
"@typescript-eslint/scope-manager" "7.2.0"
"@typescript-eslint/types" "7.2.0"
"@typescript-eslint/typescript-estree" "7.2.0"
"@typescript-eslint/visitor-keys" "7.2.0"
"@typescript-eslint/scope-manager" "8.6.0"
"@typescript-eslint/types" "8.6.0"
"@typescript-eslint/typescript-estree" "8.6.0"
"@typescript-eslint/visitor-keys" "8.6.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@5.62.0":
@@ -3527,22 +3560,6 @@
"@typescript-eslint/types" "5.62.0"
"@typescript-eslint/visitor-keys" "5.62.0"
"@typescript-eslint/scope-manager@7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz#cfb437b09a84f95a0930a76b066e89e35d94e3da"
integrity sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==
dependencies:
"@typescript-eslint/types" "7.2.0"
"@typescript-eslint/visitor-keys" "7.2.0"
"@typescript-eslint/scope-manager@8.0.0-alpha.30":
version "8.0.0-alpha.30"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.0.0-alpha.30.tgz#851e38a30884b4247485de1ad10b072c3df80a4a"
integrity sha512-FGW/iPWGyPFamAVZ60oCAthMqQrqafUGebF8UKuq/ha+e9SVG6YhJoRzurlQXOVf8dHfOhJ0ADMXyFnMc53clg==
dependencies:
"@typescript-eslint/types" "8.0.0-alpha.30"
"@typescript-eslint/visitor-keys" "8.0.0-alpha.30"
"@typescript-eslint/scope-manager@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz#834301d2e70baf924c26818b911bdc40086f7468"
@@ -3551,13 +3568,39 @@
"@typescript-eslint/types" "8.3.0"
"@typescript-eslint/visitor-keys" "8.3.0"
"@typescript-eslint/type-utils@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz#c1ae6af8c21a27254321016b052af67ddb44a9ac"
integrity sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==
"@typescript-eslint/scope-manager@8.6.0":
version "8.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz#28cc2fc26a84b75addf45091a2c6283e29e2c982"
integrity sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==
dependencies:
"@typescript-eslint/typescript-estree" "8.3.0"
"@typescript-eslint/utils" "8.3.0"
"@typescript-eslint/types" "8.6.0"
"@typescript-eslint/visitor-keys" "8.6.0"
"@typescript-eslint/scope-manager@8.7.0":
version "8.7.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz#90ee7bf9bc982b9260b93347c01a8bc2b595e0b8"
integrity sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==
dependencies:
"@typescript-eslint/types" "8.7.0"
"@typescript-eslint/visitor-keys" "8.7.0"
"@typescript-eslint/type-utils@8.6.0":
version "8.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz#d4347e637478bef88cee1db691fcfa20ade9b8a0"
integrity sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==
dependencies:
"@typescript-eslint/typescript-estree" "8.6.0"
"@typescript-eslint/utils" "8.6.0"
debug "^4.3.4"
ts-api-utils "^1.3.0"
"@typescript-eslint/type-utils@8.7.0":
version "8.7.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.7.0.tgz#d56b104183bdcffcc434a23d1ce26cde5e42df93"
integrity sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==
dependencies:
"@typescript-eslint/typescript-estree" "8.7.0"
"@typescript-eslint/utils" "8.7.0"
debug "^4.3.4"
ts-api-utils "^1.3.0"
@@ -3566,21 +3609,21 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
"@typescript-eslint/types@7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.2.0.tgz#0feb685f16de320e8520f13cca30779c8b7c403f"
integrity sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==
"@typescript-eslint/types@8.0.0-alpha.30":
version "8.0.0-alpha.30"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.0.0-alpha.30.tgz#149ae5b6aa99e2491cb79a90ad70cdbc98b7586a"
integrity sha512-4WzLlw27SO9pK9UFj/Hu7WGo8WveT0SEiIpFVsV2WwtQmLps6kouwtVCB8GJPZKJyurhZhcqCoQVQFmpv441Vg==
"@typescript-eslint/types@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.3.0.tgz#378e62447c2d7028236e55a81d3391026600563b"
integrity sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==
"@typescript-eslint/types@8.6.0":
version "8.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.6.0.tgz#cdc3a16f83f2f0663d6723e9fd032331cdd9f51c"
integrity sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==
"@typescript-eslint/types@8.7.0":
version "8.7.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.7.0.tgz#21d987201c07b69ce7ddc03451d7196e5445ad19"
integrity sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==
"@typescript-eslint/typescript-estree@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
@@ -3594,34 +3637,6 @@
semver "^7.3.7"
tsutils "^3.21.0"
"@typescript-eslint/typescript-estree@7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz#5beda2876c4137f8440c5a84b4f0370828682556"
integrity sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==
dependencies:
"@typescript-eslint/types" "7.2.0"
"@typescript-eslint/visitor-keys" "7.2.0"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
minimatch "9.0.3"
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/typescript-estree@8.0.0-alpha.30":
version "8.0.0-alpha.30"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.0-alpha.30.tgz#acd674b63e204af9022b33ff031261e2e3a88b8d"
integrity sha512-WSXbc9ZcXI+7yC+6q95u77i8FXz6HOLsw3ST+vMUlFy1lFbXyFL/3e6HDKQCm2Clt0krnoCPiTGvIn+GkYPn4Q==
dependencies:
"@typescript-eslint/types" "8.0.0-alpha.30"
"@typescript-eslint/visitor-keys" "8.0.0-alpha.30"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
minimatch "^9.0.4"
semver "^7.6.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/typescript-estree@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz#3e3d38af101ba61a8568f034733b72bfc9f176b9"
@@ -3636,25 +3651,53 @@
semver "^7.6.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/utils@8.0.0-alpha.30":
version "8.0.0-alpha.30"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.0.0-alpha.30.tgz#8102839b7b773be6572964a5bb8c378b526c78b2"
integrity sha512-rfhqfLqFyXhHNDwMnHiVGxl/Z2q/3guQ1jLlGQ0hi9Rb7inmwz42crM+NnLPR+2vEnwyw1P/g7fnQgQ3qvFx4g==
"@typescript-eslint/typescript-estree@8.6.0":
version "8.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz#f945506de42871f04868371cb5bf21e8f7266e01"
integrity sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@typescript-eslint/scope-manager" "8.0.0-alpha.30"
"@typescript-eslint/types" "8.0.0-alpha.30"
"@typescript-eslint/typescript-estree" "8.0.0-alpha.30"
"@typescript-eslint/types" "8.6.0"
"@typescript-eslint/visitor-keys" "8.6.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 "^1.3.0"
"@typescript-eslint/utils@8.3.0", "@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.3.0.tgz#b10972319deac5959c7a7075d0cf2b5e1de7ec08"
integrity sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==
"@typescript-eslint/typescript-estree@8.7.0":
version "8.7.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz#6c7db6baa4380b937fa81466c546d052f362d0e8"
integrity sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==
dependencies:
"@typescript-eslint/types" "8.7.0"
"@typescript-eslint/visitor-keys" "8.7.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 "^1.3.0"
"@typescript-eslint/utils@8.6.0":
version "8.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.6.0.tgz#175fe893f32804bed1e72b3364ea6bbe1044181c"
integrity sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@typescript-eslint/scope-manager" "8.3.0"
"@typescript-eslint/types" "8.3.0"
"@typescript-eslint/typescript-estree" "8.3.0"
"@typescript-eslint/scope-manager" "8.6.0"
"@typescript-eslint/types" "8.6.0"
"@typescript-eslint/typescript-estree" "8.6.0"
"@typescript-eslint/utils@8.7.0":
version "8.7.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.7.0.tgz#cef3f70708b5b5fd7ed8672fc14714472bd8a011"
integrity sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@typescript-eslint/scope-manager" "8.7.0"
"@typescript-eslint/types" "8.7.0"
"@typescript-eslint/typescript-estree" "8.7.0"
"@typescript-eslint/utils@^5.58.0":
version "5.62.0"
@@ -3670,6 +3713,16 @@
eslint-scope "^5.1.1"
semver "^7.3.7"
"@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/utils@^8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.3.0.tgz#b10972319deac5959c7a7075d0cf2b5e1de7ec08"
integrity sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@typescript-eslint/scope-manager" "8.3.0"
"@typescript-eslint/types" "8.3.0"
"@typescript-eslint/typescript-estree" "8.3.0"
"@typescript-eslint/visitor-keys@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
@@ -3678,22 +3731,6 @@
"@typescript-eslint/types" "5.62.0"
eslint-visitor-keys "^3.3.0"
"@typescript-eslint/visitor-keys@7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz#5035f177752538a5750cca1af6044b633610bf9e"
integrity sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==
dependencies:
"@typescript-eslint/types" "7.2.0"
eslint-visitor-keys "^3.4.1"
"@typescript-eslint/visitor-keys@8.0.0-alpha.30":
version "8.0.0-alpha.30"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.0-alpha.30.tgz#8424b5eb2ca73cb58a8fa1c4bfb6ab72693d3a7f"
integrity sha512-XZuNurZxBqmr6ZIRIwWFq7j5RZd6ZlkId/HZEWyfciK+CWoyOxSF9Pv2VXH9Rlu2ZG2PfbhLz2Veszl4Pfn7yA==
dependencies:
"@typescript-eslint/types" "8.0.0-alpha.30"
eslint-visitor-keys "^3.4.3"
"@typescript-eslint/visitor-keys@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz#320d747d107af1eef1eb43fbc4ccdbddda13068b"
@@ -3702,6 +3739,22 @@
"@typescript-eslint/types" "8.3.0"
eslint-visitor-keys "^3.4.3"
"@typescript-eslint/visitor-keys@8.6.0":
version "8.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz#5432af4a1753f376f35ab5b891fc9db237aaf76f"
integrity sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==
dependencies:
"@typescript-eslint/types" "8.6.0"
eslint-visitor-keys "^3.4.3"
"@typescript-eslint/visitor-keys@8.7.0":
version "8.7.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz#5e46f1777f9d69360a883c1a56ac3c511c9659a8"
integrity sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==
dependencies:
"@typescript-eslint/types" "8.7.0"
eslint-visitor-keys "^3.4.3"
"@ungap/structured-clone@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
@@ -3881,7 +3934,7 @@ array.prototype.findlast@^1.2.5:
es-object-atoms "^1.0.0"
es-shim-unscopables "^1.0.2"
array.prototype.findlastindex@^1.2.3:
array.prototype.findlastindex@^1.2.3, array.prototype.findlastindex@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz#8c35a755c72908719453f87145ca011e39334d0d"
integrity sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==
@@ -3965,11 +4018,16 @@ available-typed-arrays@^1.0.7:
dependencies:
possible-typed-array-names "^1.0.0"
axe-core@^4.9.1:
axe-core@^4.10.0, axe-core@^4.9.1:
version "4.10.0"
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.0.tgz#d9e56ab0147278272739a000880196cdfe113b59"
integrity sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==
axobject-query@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==
axobject-query@~3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.1.1.tgz#3b6e5c6d4e43ca7ba51c5babf99d22a9c68485e1"
@@ -5121,14 +5179,15 @@ escodegen@^2.0.0:
optionalDependencies:
source-map "~0.6.1"
eslint-config-next@14.2.7:
version "14.2.7"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-14.2.7.tgz#25c5c821740453db5e5fc8a857ec80c4784f36d5"
integrity sha512-ppmy+QdQ7qkuCHGDlPjWaoSbJvjGpWSBD4zEW8f1eWlxYXYpZK7QzBOer1EcHKT3uKhlY1JjUus9g7Kvv712rw==
eslint-config-next@14.2.13:
version "14.2.13"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-14.2.13.tgz#9e3e3b86e8929f05837a6e9f2aee4f93cd6a1056"
integrity sha512-aro1EKAoyYchnO/3Tlo91hnNBO7QO7qnv/79MAFC+4Jq8TdUVKQlht5d2F+YjrePjdpOvfL+mV9JPfyYNwkk1g==
dependencies:
"@next/eslint-plugin-next" "14.2.7"
"@next/eslint-plugin-next" "14.2.13"
"@rushstack/eslint-patch" "^1.3.3"
"@typescript-eslint/parser" "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0"
"@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"
eslint-import-resolver-node "^0.3.6"
eslint-import-resolver-typescript "^3.5.2"
eslint-plugin-import "^2.28.1"
@@ -5171,7 +5230,38 @@ eslint-module-utils@^2.8.0, eslint-module-utils@^2.8.1:
dependencies:
debug "^3.2.7"
eslint-plugin-import@2.29.1, eslint-plugin-import@^2.28.1:
eslint-module-utils@^2.9.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.11.0.tgz#b99b211ca4318243f09661fae088f373ad5243c4"
integrity sha512-gbBE5Hitek/oG6MUVj6sFuzEjA/ClzNflVrLovHi/JgLdC7fiN5gLAY1WIPW1a0V5I999MnsrvVrCOGmmVqDBQ==
dependencies:
debug "^3.2.7"
eslint-plugin-import@2.30.0:
version "2.30.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz#21ceea0fc462657195989dd780e50c92fe95f449"
integrity sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==
dependencies:
"@rtsao/scc" "^1.1.0"
array-includes "^3.1.8"
array.prototype.findlastindex "^1.2.5"
array.prototype.flat "^1.3.2"
array.prototype.flatmap "^1.3.2"
debug "^3.2.7"
doctrine "^2.1.0"
eslint-import-resolver-node "^0.3.9"
eslint-module-utils "^2.9.0"
hasown "^2.0.2"
is-core-module "^2.15.1"
is-glob "^4.0.3"
minimatch "^3.1.2"
object.fromentries "^2.0.8"
object.groupby "^1.0.3"
object.values "^1.2.0"
semver "^6.3.1"
tsconfig-paths "^3.15.0"
eslint-plugin-import@^2.28.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz#d45b37b5ef5901d639c15270d74d46d161150643"
integrity sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==
@@ -5194,14 +5284,36 @@ eslint-plugin-import@2.29.1, eslint-plugin-import@^2.28.1:
semver "^6.3.1"
tsconfig-paths "^3.15.0"
eslint-plugin-jest@28.8.1:
version "28.8.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-28.8.1.tgz#3cf7d2752350bd711ca0de000b048a5464b52e0b"
integrity sha512-G46XMyYu6PtSNJUkQ0hsPjzXYpzq/O4vpCciMizTKRJG8kNsRreGoMRDG6H9FIB/xVgfFuclVnuX4XRvFUzrZQ==
eslint-plugin-jest@28.8.3:
version "28.8.3"
resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-28.8.3.tgz#c5699bba0ad06090ad613535e4f1572f4c2567c0"
integrity sha512-HIQ3t9hASLKm2IhIOqnu+ifw7uLZkIlR7RYNv7fMcEi/p0CIiJmfriStQS2LDkgtY4nyLbIZAD+JL347Yc2ETQ==
dependencies:
"@typescript-eslint/utils" "^6.0.0 || ^7.0.0 || ^8.0.0"
eslint-plugin-jsx-a11y@6.9.0, eslint-plugin-jsx-a11y@^6.7.1:
eslint-plugin-jsx-a11y@6.10.0:
version "6.10.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.0.tgz#36fb9dead91cafd085ddbe3829602fb10ef28339"
integrity sha512-ySOHvXX8eSN6zz8Bywacm7CvGNhUtdjvqfQDVe6020TUK34Cywkw7m0KsCCk1Qtm9G1FayfTN1/7mMYnYO2Bhg==
dependencies:
aria-query "~5.1.3"
array-includes "^3.1.8"
array.prototype.flatmap "^1.3.2"
ast-types-flow "^0.0.8"
axe-core "^4.10.0"
axobject-query "^4.1.0"
damerau-levenshtein "^1.0.8"
emoji-regex "^9.2.2"
es-iterator-helpers "^1.0.19"
hasown "^2.0.2"
jsx-ast-utils "^3.3.5"
language-tags "^1.0.9"
minimatch "^3.1.2"
object.fromentries "^2.0.8"
safe-regex-test "^1.0.3"
string.prototype.includes "^2.0.0"
eslint-plugin-jsx-a11y@^6.7.1:
version "6.9.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.9.0.tgz#67ab8ff460d4d3d6a0b4a570e9c1670a0a8245c8"
integrity sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==
@@ -5243,7 +5355,31 @@ eslint-plugin-prettier@5.2.1:
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596"
integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==
eslint-plugin-react@7.35.0, eslint-plugin-react@^7.33.2:
eslint-plugin-react@7.37.0:
version "7.37.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.0.tgz#c21f64a32fc34df1eaeca571ec8f70bdc40dd20a"
integrity sha512-IHBePmfWH5lKhJnJ7WB1V+v/GolbB0rjS8XYVCSQCZKaQCAUhMoVoOEn1Ef8Z8Wf0a7l8KTJvuZg5/e4qrZ6nA==
dependencies:
array-includes "^3.1.8"
array.prototype.findlast "^1.2.5"
array.prototype.flatmap "^1.3.2"
array.prototype.tosorted "^1.1.4"
doctrine "^2.1.0"
es-iterator-helpers "^1.0.19"
estraverse "^5.3.0"
hasown "^2.0.2"
jsx-ast-utils "^2.4.1 || ^3.0.0"
minimatch "^3.1.2"
object.entries "^1.1.8"
object.fromentries "^2.0.8"
object.values "^1.2.0"
prop-types "^15.8.1"
resolve "^2.0.0-next.5"
semver "^6.3.1"
string.prototype.matchall "^4.0.11"
string.prototype.repeat "^1.0.0"
eslint-plugin-react@^7.33.2:
version "7.35.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.35.0.tgz#00b1e4559896710e58af6358898f2ff917ea4c41"
integrity sha512-v501SSMOWv8gerHkk+IIQBkcGRGrO2nfybfj5pLxuJNFTPxxA3PSryhXTK+9pNbtkggheDdsC0E9Q8CuPk6JKA==
@@ -6004,7 +6140,14 @@ i18next-parser@9.0.2:
vinyl "~3.0.0"
vinyl-fs "^4.0.0"
i18next@23.14.0, i18next@^23.5.1:
i18next@23.15.1:
version "23.15.1"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.15.1.tgz#c50de337bf12ca5195e697cc0fbe5f32304871d9"
integrity sha512-wB4abZ3uK7EWodYisHl/asf8UYEhrI/vj/8aoSsrj/ZDxj4/UXPOa1KvFt1Fq5hkUHquNqwFlDprmjZ8iySgYA==
dependencies:
"@babel/runtime" "^7.23.2"
i18next@^23.5.1:
version "23.14.0"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.14.0.tgz#d415a858390cc849f3db0df539cb2bbfe24a3cdb"
integrity sha512-Y5GL4OdA8IU2geRrt2+Uc1iIhsjICdHZzT9tNwQ3TVqdNzgxHToGCKf/TPRP80vTCAP6svg2WbbJL+Gx5MFQVA==
@@ -6146,7 +6289,7 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7:
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
is-core-module@^2.13.0, is-core-module@^2.13.1:
is-core-module@^2.13.0, is-core-module@^2.13.1, is-core-module@^2.15.1:
version "2.15.1"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37"
integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==
@@ -7165,13 +7308,6 @@ min-indent@^1.0.0:
resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
minimatch@9.0.3:
version "9.0.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
dependencies:
brace-expansion "^2.0.1"
minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -7233,12 +7369,12 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next@14.2.7:
version "14.2.7"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.7.tgz#e02d5d9622ff4b998e5c89adfd660c9bf6435970"
integrity sha512-4Qy2aK0LwH4eQiSvQWyKuC7JXE13bIopEQesWE0c/P3uuNRnZCQanI0vsrMLmUQJLAto+A+/8+sve2hd+BQuOQ==
next@14.2.13:
version "14.2.13"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.13.tgz#32da2ee0afbe729e2d4a467c3570def90e1c974d"
integrity sha512-BseY9YNw8QJSwLYD7hlZzl6QVDoSFHL/URN5K64kVEVpCsSOWeyjbIGK+dZUaRViHTaMQX8aqmnn0PHBbGZezg==
dependencies:
"@next/env" "14.2.7"
"@next/env" "14.2.13"
"@swc/helpers" "0.5.5"
busboy "1.6.0"
caniuse-lite "^1.0.30001579"
@@ -7246,15 +7382,15 @@ next@14.2.7:
postcss "8.4.31"
styled-jsx "5.1.1"
optionalDependencies:
"@next/swc-darwin-arm64" "14.2.7"
"@next/swc-darwin-x64" "14.2.7"
"@next/swc-linux-arm64-gnu" "14.2.7"
"@next/swc-linux-arm64-musl" "14.2.7"
"@next/swc-linux-x64-gnu" "14.2.7"
"@next/swc-linux-x64-musl" "14.2.7"
"@next/swc-win32-arm64-msvc" "14.2.7"
"@next/swc-win32-ia32-msvc" "14.2.7"
"@next/swc-win32-x64-msvc" "14.2.7"
"@next/swc-darwin-arm64" "14.2.13"
"@next/swc-darwin-x64" "14.2.13"
"@next/swc-linux-arm64-gnu" "14.2.13"
"@next/swc-linux-arm64-musl" "14.2.13"
"@next/swc-linux-x64-gnu" "14.2.13"
"@next/swc-linux-x64-musl" "14.2.13"
"@next/swc-win32-arm64-msvc" "14.2.13"
"@next/swc-win32-ia32-msvc" "14.2.13"
"@next/swc-win32-x64-msvc" "14.2.13"
no-case@^3.0.4:
version "3.0.4"
@@ -7364,7 +7500,7 @@ object.fromentries@^2.0.7, object.fromentries@^2.0.8:
es-abstract "^1.23.2"
es-object-atoms "^1.0.0"
object.groupby@^1.0.1:
object.groupby@^1.0.1, object.groupby@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e"
integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==
@@ -7545,17 +7681,17 @@ pkg-dir@^4.2.0:
dependencies:
find-up "^4.0.0"
playwright-core@1.46.1:
version "1.46.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.46.1.tgz#28f3ab35312135dda75b0c92a3e5c0e7edb9cc8b"
integrity sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==
playwright-core@1.47.2:
version "1.47.2"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.47.2.tgz#7858da9377fa32a08be46ba47d7523dbd9460a4e"
integrity sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==
playwright@1.46.1:
version "1.46.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.46.1.tgz#ea562bc48373648e10420a10c16842f0b227c218"
integrity sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==
playwright@1.47.2:
version "1.47.2"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.47.2.tgz#155688aa06491ee21fb3e7555b748b525f86eb20"
integrity sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==
dependencies:
playwright-core "1.46.1"
playwright-core "1.47.2"
optionalDependencies:
fsevents "2.3.2"
@@ -7887,12 +8023,12 @@ react-hook-form@7.53.0:
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.0.tgz#3cf70951bf41fa95207b34486203ebefbd3a05ab"
integrity sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==
react-i18next@15.0.1:
version "15.0.1"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.0.1.tgz#fc662d93829ecb39683fe2757a47ebfbc5c912a0"
integrity sha512-NwxLqNM6CLbeGA9xPsjits0EnXdKgCRSS6cgkgOdNcPXqL+1fYNl8fBg1wmnnHvFy812Bt4IWTPE9zjoPmFj3w==
react-i18next@15.0.2:
version "15.0.2"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.0.2.tgz#8b7f3c0e66cb4f99f95e2c507353398c41a68cc2"
integrity sha512-z0W3/RES9Idv3MmJUcf0mDNeeMOUXe+xoL0kPfQPbDoZHmni/XsIoq5zgT2MCFUiau283GuBUK578uD/mkAbLQ==
dependencies:
"@babel/runtime" "^7.24.8"
"@babel/runtime" "^7.25.0"
html-parse-stringify "^3.0.1"
react-is@18.2.0:
@@ -7930,10 +8066,10 @@ react-modal@3.16.1:
react-lifecycles-compat "^3.0.0"
warning "^4.0.3"
react-select@5.8.0:
version "5.8.0"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.8.0.tgz#bd5c467a4df223f079dd720be9498076a3f085b5"
integrity sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==
react-select@5.8.1:
version "5.8.1"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.8.1.tgz#3284a93b7633b5e893306b2a8007ea0f793e62b9"
integrity sha512-RT1CJmuc+ejqm5MPgzyZujqDskdvB9a9ZqrdnVLsvAHjJ3Tj0hELnLeVPQlmYdVKCdCpxanepl6z7R5KhXhWzg==
dependencies:
"@babel/runtime" "^7.12.0"
"@emotion/cache" "^11.4.0"
@@ -8434,7 +8570,16 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8528,7 +8673,14 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -8845,7 +8997,7 @@ tr46@~0.0.3:
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
ts-api-utils@^1.0.1, ts-api-utils@^1.3.0:
ts-api-utils@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1"
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
@@ -8982,10 +9134,10 @@ typed-array-length@^1.0.6:
is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0"
typescript@*, typescript@5.5.4, typescript@^5.0.4:
version "5.5.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
typescript@*, typescript@5.6.2, typescript@^5.0.4:
version "5.6.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0"
integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==
unbox-primitive@^1.0.2:
version "1.0.2"
@@ -9343,7 +9495,16 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+2 -1
View File
@@ -6,8 +6,9 @@ kind: Job
metadata:
name: {{ $fullName }}-migrate
namespace: {{ .Release.Namespace | quote }}
{{- with .Values.backend.migrateJobAnnotations }}
annotations:
argocd.argoproj.io/sync-options: Replace=true,Force=true
{{- with .Values.backend.migrateJobAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
@@ -6,8 +6,9 @@ kind: Job
metadata:
name: {{ $fullName }}-createsuperuser
namespace: {{ .Release.Namespace | quote }}
{{- with .Values.backend.migrateJobAnnotations }}
annotations:
argocd.argoproj.io/sync-options: Replace=true,Force=true
{{- with .Values.backend.migrateJobAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
+9 -2
View File
@@ -3,15 +3,22 @@ kind: Secret
metadata:
name: backend
namespace: {{ .Release.Namespace | quote }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
stringData:
DJANGO_SUPERUSER_EMAIL: {{ .Values.djangoSuperUserEmail }}
DJANGO_SUPERUSER_PASSWORD: {{ .Values.djangoSuperUserPass }}
DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }}
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
{{ if .Values.resourceServer }}
{{- if .Values.resourceServer }}
OIDC_RS_CLIENT_ID: {{ .Values.resourceServer.clientId }}
OIDC_RS_CLIENT_SECRET: {{ .Values.resourceServer.clientSecret }}
OIDC_RS_PRIVATE_KEY_STR: |
{{ .Values.resourceServer.privateKey | indent 4 }}
{{ end }}
{{- end }}
{{- if .Values.mail_provisioning_api_credentials }}
MAIL_PROVISIONING_API_CREDENTIALS: {{ .Values.mail_provisioning_api_credentials }}
{{- end }}
+42 -41
View File
@@ -1,8 +1,9 @@
djangoSecretKey: ENC[AES256_GCM,data:9fOtt8oesY2CUahg972UGldDrqqF6Fa1Tn+bKxNpMbfXppQtPY2Jfu4EWKAaqH07X00=,iv:OC0ggDgCcja6h4IK73jVXZGDE1qp5OJfeNg182DKxQ4=,tag:ITMAWmPxW8lNBvm2Xefw/Q==,type:str]
djangoSuperUserPass: ENC[AES256_GCM,data:mkLVMnc=,iv:qYBUdUwJk422RVm23/6CUKubFtBL+lofynSnkJglNQk=,tag:Md5FPXwCe9kl5BkICHszzg==,type:str]
djangoSecretKey: ENC[AES256_GCM,data:cjXSpQ+4IlpM6bn0fGZ6tdhWdFwppPuOwBdPEjI5kYEnj79aMalYzUUiEPImAa8/d80=,iv:h4grzvAWhya7Gz75EoQHQN0Hp0fl7I21/6G0Xx0n2P8=,tag:QKg755yZt1gm+dyVRfGBzQ==,type:str]
djangoSuperUserPass: ENC[AES256_GCM,data:GauOc+A=,iv:GAY2S6LSS3c8y4XtpyWP0jMteYjNYnAS++VFyQIWVXM=,tag:xy9IsOIpgYYieVZVgzAhcQ==,type:str]
mail_provisioning_api_credentials: ENC[AES256_GCM,data:FQdonFAixRpzMbvjjltIUvwZ3B5e7rRs,iv:zz0GynN5rxzLWAAW1Gr5RFZ0AnPBrwUlOM0tPTrDq0s=,tag:1xmZthk6+H4KCWHwjFoJPA==,type:str]
oidc:
clientId: ENC[AES256_GCM,data:gcxd+bMz/YdGw/wrCx1HvSOC5pWkUfuLulU4LPEFtMj+z0W8,iv:7enZhQGxQ2voA72bjGWfMl7yf+ArFgQ/eAspAjRa3p0=,tag:A6Im4qDckaPdX8pdS/lyuw==,type:str]
clientSecret: ENC[AES256_GCM,data:AmEnaHhdCzynw1zhPHwotJ+TUI9DJ11X4ScjGzU4ADOyAJeJp8gWLFuU2GG1mWCOBPjtVOEdaN1ZTZNKKHS9qA==,iv:8oIehcSJHiD1a6C7Jv8rJz2ixakQTpOWYRAr7Ifj2yE=,tag:keKNxLl9jChB/pm52gddhA==,type:str]
clientId: ENC[AES256_GCM,data:0mjYen3Pu6/mjInlwic+IizFaMtpstUxkB5hiaxBE4eaYrf2,iv:pb+q3KPgAqWY3/xBAyF9gHyEnmFbQQsJiXKGnCxJJhU=,tag:P6jH8LS6DZb1l2KZTkMFdg==,type:str]
clientSecret: ENC[AES256_GCM,data:/1wTDUcZglxhosbQqeJBCYf1CkhUq/bXx8RcogMsc1VVumfIyJEgKuF66BAcEuDOD7h16fZwyjCD6N9M/Uj43w==,iv:o1dwPf+H0uKRU2Rbx5UM2djB3sFDTKKdG/lXZ74e2Cc=,tag:Kay/9Jqf6SDAXMl3/eg5LQ==,type:str]
sops:
kms: []
gcp_kms: []
@@ -12,59 +13,59 @@ sops:
- recipient: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvOGdBRHB1L1RaVVBBb20z
anpzTS9BNHVYYXhISFNKSGRTTHlGWCtWdDNvCk9pdnIwWW9XTG9iWnAySXB6M3Rm
NDFZV3VCVTh2N1poL2RQeUtiU3VIcWcKLS0tIDdyKzRWYmp4WjZGMlg4eGNkdnNQ
NzdGQWtUaWtlS2xneDVUa21ucUJ3SnMKenloUQTumKE0Q8Zp8hLiFwZiGF+78HtB
lt6aEaOgIu2vc4KC1/9iUK+uPhjQC3ajOQ6G2jcRaoR+BFVlxv1Mug==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwUFg5QjJtYk52a2V1YXlC
bFBsTlZNS0tsMkNoRzVEenprV29hbGtOM0dRClVOVVVlemRzcTRpY2pSMDdQMGNY
d1JYU2kyZWgxaGlRejdKb0x6YnB0L0kKLS0tIDB2dklJWWtGNi84TCtWcTFEUEkw
eWkrU1lET0xwZHZwTHJvNE53Mm9Cc3MKNCtVwUNnllvg228ax4z4oHhA383zvkhN
2FAEOnKe94x3e2st4WrLK+fdO/4wE544ykCm2sUzXJfTN1g6N8YPyA==
-----END AGE ENCRYPTED FILE-----
- recipient: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1TlZuVTEyVzVHb1djandk
K2FxZGNlZG9vNllMTnVNZ2pZampnd25pOGxVCkJDUi9YcFVrcVcyOEhKWjBob09M
d0hRc0pkUXhPbTNrS0RSN3NJa2dwbkUKLS0tIG5OSUU4R2s3REV5TWd4Ym5zdWln
ZVcySnhYY2JydmVwOCtEZVhOcTNkQlUKhhZK7CE5bPKbqzmQp7mIL3Lmb8+X+8js
PS55Dv9ivffm+XYKh2tjh3At9+FLNfOECwZBC+KrAQQs0W+vBaXWxQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzeE9WdE43cmFxT2FLMU00
RTdXek1xNUVseXpKeWR5Y3VEUExMSUxrQkhvClkvMUQvM0tsM3JTSUZaQjVXaGR4
QXBMQTU2UFVHSlFhK1NsVFFmM3k5NncKLS0tIE1VcjhTQ1JLdndyT2lqbGtYeFNC
TFp0N1BSbGxkQkFNSTJZN0tod1VIdE0KtqKga/vF4AfRMOr8MsHObXRAWbwQNCgY
We6JuFyJ+qL90TT6aFe7HfZP6m+LfiweOpNkzHj7TknbvSlJ/VzJZQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqOWlUazJpNmhOQ0xYQ0pM
aVRia3B6anErRHNDSTFKT1hWZnZ2blZEeGhZCjNkSm5BZ2hEMVA0dGlSTGo5cWd3
U1FZWnNwSkJhSHNRRDc3QVUrakxad2cKLS0tIEV3ZzVVZ0ZJVytKdzFHSEREcHVq
SUtrZXh6TktaUHZqZTdzL3dZbVdiblkKiJliMwXPs/EJVFuEnegqWKvO3axHJEw7
/Y5qgNPN8MDJrcMtDdcFAKkdrUUUhPgzd1jHeNWlw9tPkqgmoNe1/w==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1UzVZZWh1djdNQUg4VGlK
WGhQT1JTcEV1Nm5qYUp3dEpvVHc0YWV5cVIwCmR2Q1pPekZJSExYQTNRM0RacTk0
QlkvV3g1aHhJVGRQNmYrb1dpRmFhZ00KLS0tIDJtUmRUTWdHRnJ6RVFNNS9JaTRX
ajUyZjR0VkRER2t3cHhTeU1TQzBzSFEKkmVOrv1G5/1DO1l9LNNZHryb7iMnDEVW
3gokQTKAcYd0D5G295/8Mvrm2E7j54KP5WhVjDN/f9Lg2liYEnS06Q==
-----END AGE ENCRYPTED FILE-----
- recipient: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWS3YzWjVlazVDRC9iNTM2
K0VYQ2dQclIvVlFFRmlnbXFhUHVneWl0WHdZCnR1Y2RzMGxzWWRxL2ppYXJVUGhO
TGdld0tLaURiYlMwR3ByL1phZTNnN1UKLS0tIFZ3QVUyVlBpNGZjdHBKL3JHNnFU
YklMbW15Mm9EdnVJbkRLb3drekp3Zm8KrzAAV2EKHHkJzpCBerHkqlI122OUNM/o
3gIX838hJgatKKOO1FipeuzOTwlWEVOwP/iBnHnMe/QdJdsk6issqQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoblBGaDNobGtMc3FEK0pM
QTRKUURXdUxkcGZlaGlCYWJxN0dqWHhKWlhzClAzM1F1VHVFMjZzaURvSVpZdC9C
KzlBRWgrallpMWFpT2pzL0Q2RTF4eEEKLS0tIDk2QjNqZDNMZEdKVmdZRFlyRWZD
WE5abEdmQ29zTWJqZlBwbkdJQlNQZHMKfjtz47qD3BRVNIJ2hyyBWE/+Xj5AqY9q
vZ3T7HPq2qGZIqgFNXQAyv1pgy2ulgWdxP/fawRcR/xWerb7+PkVFg==
-----END AGE ENCRYPTED FILE-----
- recipient: age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1a21pZHNUdUtqQUZ6Z3lm
VDFORFpYNlFYUFkwRVZkS3JKR0lnVTNzdlJnCkZlTi9LMXZBMEJ1Uml1cDh4ZHRy
bGlEcjEwUlp6RWJxTnhVNE5nNkxtTVkKLS0tIFV5WERuZiszNHFjd0V5cFlJWjAw
T3RVTDRZRjRUNTkwV1pQa2NSMkgydTQK8+hb0NP+Egyq4s7SmI5MPLFKJ3Jlztbh
GqOJ6vzORZwc/jvry2IMqtiZfDsdEYTAvMz16aaxcSMCmvFuXdtmvg==
-----END AGE ENCRYPTED FILE-----
- recipient: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhemxEZWcwTWQrM0lOd2ky
MVNtcGs3UGphSlZleGhtTFh3andSa00xdmhjCml4cGd1bHVYVzk4djA5QndpQ291
Y0tOSlpoMytvRE41WXliMitEUVZ2ZkkKLS0tIGNoK2xCc3FKNXhhbkErbStyQ0lC
VWpzS04rdkJ3M3BqTTY1T2RyTGd6OTgK0sDGDG3R7fDFwhgn6gdYGDUC9kWFk11e
hn69zBqKXvT7jcQoEWASmbRJ0kYTF/Rg9stWASYfCT+dyEkDfVewPw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3aUJKNlZPemdaY1BBdS8z
RC9UMTdmYVBsNFpjcWtrVVZiTHVtcUNnWjNrClBYWmFMTHpUT2tERG1JUHFPUm1y
bndCZHdoaWlhL3Iza3h0YUh3WWRJZ2MKLS0tIENiV1ZvV2luOFI0b0h5Q0xJQ21M
NjlseVc1TzUyNmczRVNHMThxZVlaVVUKRCqwzf0JfHRODTCWb6hS8lz0qqjx2GPS
zCMU8WcNi0Afj0h32q7xCZzRZP5vitzA2Ro9510VwQp0sN6C6rKR+g==
-----END AGE ENCRYPTED FILE-----
- recipient: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyelo4b05STnFVVGNPNUdH
bDdiQjQ4WkNlY1dacTZRU3ZObEU3WkFyRUFjClRmOFAyeHRoT2U5Rzc1OTRmRjho
bUo5WjljZzNtNVQ5RlhrdmVpYjhuOE0KLS0tIE8zWEUwL3dyWDZvamdKQk1qcDVR
b2g2SFNDMHZvSTNOYUQ0Rms1RlVBem8KacFpoySUpdGChbGU9PHkefzE5WTw5X9g
du7vbHxqE8M3sjH3TvbB7psj9ISQ/mJ15yvFrIvQUaZ1nQf91b2nHg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2024-04-23T08:10:56Z"
mac: ENC[AES256_GCM,data:9maAsoIjrdzZUKqmbsv9iOrxlH5rRF0XJ8+UBqldevEHmfSywKyiRtstMTDVBeJXey6Y0D5V88nXtpZKerRWTpcR+lu8gzGzf1nLZ9r72ldInxXuJPmalQIo6Y4MD+hrOzCbq0i6IQWfTlHpVVz4KulFeAsNyJlD3KZPFsuD6pY=,iv:pxJfbVRCDO9ikionNoy0JvGLgPG2HV805wGprQMV4OE=,tag:zhH5HjyrS0cVDl6dG/9SkQ==,type:str]
lastmodified: "2024-09-11T14:03:37Z"
mac: ENC[AES256_GCM,data:OpSIQiyQ7FlIGJnh+T2rxxHETewt27jURPqbAxlfusS4yY2pxxK40Cdv7QLUcoNKOuERnWth1qKGeY4uXqxXQK72hq4DWR32t6SoX795f/W8zqtBN378wrOQIgXwamzZQeY5aV/9NNHDTVnpjdGwGF3R+WYlfk3HpAKx/yNx5xg=,iv:EZwG1RnfoU6HTV6j0+FgrxdMNMUWT/tpO4iZubLQxkI=,tag:lwnNRrGDwRg18quW2ZHxCg==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.8.1
version: 3.9.0
+7 -1
View File
@@ -50,7 +50,13 @@ backend:
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
MAIL_PROVISIONING_API_URL: "http://host.docker.internal:8000"
WEBMAIL_URL: "https://onestendev.yapasdewebmail.fr"
MAIL_PROVISIONING_API_URL: "http://host.docker.internal:8001"
MAIL_PROVISIONING_API_CREDENTIALS:
secretKeyRef:
name: backend
key: MAIL_PROVISIONING_API_CREDENTIALS
SENTRY_DSN: "https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171"
command:
- "gunicorn"
- "-c"
@@ -1,7 +1,7 @@
image:
repository: lasuite/people-backend
pullPolicy: Always
tag: "v1.0.2"
tag: "v1.2.0"
backend:
migrateJobAnnotations:
@@ -84,8 +84,14 @@ backend:
secretKeyRef:
name: redis.redis.libre.sh
key: url
WEBMAIL_URL: "https://webmail.test.ox.numerique.gouv.fr"
MAIL_PROVISIONING_API_URL: "https://api.dev.ox.numerique.gouv.fr"
MAIL_PROVISIONING_API_CREDENTIALS:
secretKeyRef:
name: backend
key: MAIL_PROVISIONING_API_CREDENTIALS
FEATURE_TEAMS: False
SENTRY_DSN: "https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171"
createsuperuser:
command:
@@ -1,7 +1,7 @@
image:
repository: lasuite/people-backend
pullPolicy: Always
tag: "v1.0.2"
tag: "v1.2.0"
backend:
migrateJobAnnotations:
@@ -84,8 +84,14 @@ backend:
secretKeyRef:
name: redis.redis.libre.sh
key: url
WEBMAIL_URL: "https://webmail.numerique.gouv.fr"
MAIL_PROVISIONING_API_URL: "https://api.dev.ox.numerique.gouv.fr"
MAIL_PROVISIONING_API_CREDENTIALS:
secretKeyRef:
name: backend
key: MAIL_PROVISIONING_API_CREDENTIALS
FEATURE_TEAMS: False
SENTRY_DSN: "https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171"
createsuperuser:
command:
@@ -98,8 +98,13 @@ backend:
secretKeyRef:
name: redis.redis.libre.sh
key: url
WEBMAIL_URL: "https://webmail.test.ox.numerique.gouv.fr"
MAIL_PROVISIONING_API_URL: "https://api.dev.ox.numerique.gouv.fr"
MAIL_PROVISIONING_API_CREDENTIALS:
secretKeyRef:
name: backend
key: MAIL_PROVISIONING_API_CREDENTIALS
SENTRY_DSN: "https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171"
createsuperuser:
command:
+47
View File
@@ -0,0 +1,47 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section background-url="{% base64_static 'images/mail-header-background.png' %}" background-size="cover" background-repeat="no-repeat" background-position="0 -30px">
<mj-column>
<mj-image align="center" src="{% base64_static 'images/logo-suite-numerique.png' %}" width="250px" align="left" alt="{% trans 'La Suite Numérique' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-column>
<mj-text font-size="14px">
<p>{{ title }}</p>
</mj-text>
<!-- Welcome Message -->
<mj-text>
<h1>{% trans "Your new mailbox is ready !" %}</h1>
</mj-text>
<mj-divider border-width="1px" border-style="solid" border-color="#DDDDDD" width="30%" align="left"/>
<mj-image src="{% base64_static 'images/logo.png' %}" width="157px" align="left" alt="{% trans 'Logo' %}" />
<!-- Main Message -->
<mj-text>{% trans "Here are your credentials for your new mailbox ! " %}</mj-text>
<mj-text>{% trans "Email address : "%}{{ mailbox_data.email }}</mj-text>
<mj-text>{% trans "Temporary password : "%}{{ mailbox_data.password }}</mj-text>
<mj-text>{% trans "You can access your mails on " %}<a href="//{{ webmail_url }}">{{ webmail_url }}</a>.</mj-text>
<mj-button href="//{{ site.domain }}" background-color="#000091" color="white" padding-bottom="30px">
{% trans "Visit La Régie" %}
</mj-button>
<!-- Signature -->
<mj-text>
<p>{% trans "Sincerely," %}</p>
<p>{% trans "The La Suite Numérique Team" %}</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>