Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de46a50e8d | |||
| e6ed3c3be2 | |||
| f04c8bc6aa | |||
| ae05b430db | |||
| c98d173c0a | |||
| dddc281778 | |||
| bb2279b6d5 | |||
| 5420b20f43 | |||
| fbb2accefb | |||
| 75008d3e9a | |||
| c4c3e9de96 | |||
| 00816e097c | |||
| 976c955126 | |||
| 2967f1bff2 | |||
| a15d548ac4 | |||
| 7c6b3307fa | |||
| 5ded297df6 | |||
| b69ce001c8 | |||
| c0cd136618 | |||
| ee5a785d43 | |||
| 3d7dfa019c | |||
| cc9303bc9c | |||
| 34341e6f74 | |||
| 01abc66e59 | |||
| 55d7e846d8 | |||
| 232ea97d87 | |||
| a7afa68afa | |||
| 932ffb3bcb | |||
| d0254ce963 | |||
| cd1dcf11d5 | |||
| 59468aaa12 | |||
| dd8bd2a89b | |||
| cc86a3bd61 | |||
| 7f31a2b820 | |||
| aaca8819b3 | |||
| 77cc64a6c7 | |||
| aaad48480a | |||
| 00dafd4b15 | |||
| 864702d0ee | |||
| 29904ef7b6 | |||
| ba30b1d3ee | |||
| 9503b073b6 | |||
| e4aed82ff2 | |||
| 25898bbb64 | |||
| b5d8e92d1e | |||
| 237d64b4c5 | |||
| 0d157d3f2b | |||
| d291e55a9e | |||
| e7aebfe59e | |||
| bd6cd59df6 | |||
| 874ce18134 | |||
| 4fe74733a5 | |||
| 4b47f80cab | |||
| ba631fafb9 | |||
| ce15e8a3ed | |||
| 55dc342a8b | |||
| 05c8f636dd |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
+173
@@ -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
|
||||
+139
@@ -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
|
||||
+260
@@ -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
|
||||
+118
@@ -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
|
||||
+295
@@ -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",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
+16
-5
@@ -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)}
|
||||
/>
|
||||
|
||||
+85
-48
@@ -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">
|
||||
|
||||
+67
-24
@@ -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
|
||||
+241
@@ -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();
|
||||
});
|
||||
});
|
||||
+237
@@ -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';
|
||||
|
||||
+142
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+103
@@ -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');
|
||||
});
|
||||
});
|
||||
+24
-4
@@ -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(
|
||||
|
||||
+3
@@ -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 |
+49
-17
@@ -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
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
+7
-6
@@ -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}
|
||||
|
||||
+26
-1
@@ -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
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
+149
@@ -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 n’arrivez 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 d’accessibilité qui vous empêche d’accéder à un contenu ou à un des services du portail et vous n’avez 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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": "*"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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==
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user