Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f85e66abe | |||
| 6ae195b90c | |||
| be64abb22f | |||
| ca56eb0cac | |||
| 99ca34719f | |||
| 014fb62f95 | |||
| 99433a6722 | |||
| 5ebc88bcff | |||
| a65e61bd96 | |||
| b8beb56135 | |||
| 37e5b5b346 | |||
| 31201fbd59 | |||
| eb0683ffe0 | |||
| 54219d25b8 | |||
| 89e7703d53 | |||
| 7d44e54913 | |||
| 01583ba94f | |||
| 5feee53bdd | |||
| 9c62efc9f8 | |||
| d2ef9e0beb | |||
| bc1cbef168 | |||
| 8ab1b2e2ef | |||
| f498e3b6d2 | |||
| e47573e22f | |||
| c7aab6f5b5 | |||
| 2144ad5da1 | |||
| f00deeebe9 | |||
| 30aea9b350 | |||
| e01b422c13 | |||
| 040f949e5e | |||
| befcecf33c | |||
| c5edabf3b0 | |||
| 3a2b5a6428 | |||
| 15337a2226 | |||
| 0f7e312eb6 | |||
| 23561cd0e0 | |||
| 53d0336755 | |||
| b79e12b4be | |||
| c237bb4b10 | |||
| 64068efff4 | |||
| 6866babb32 | |||
| 80caa062e9 | |||
| 513dbe6f83 | |||
| 490fdd2ed4 | |||
| fa7d24545f | |||
| e5938e144e | |||
| 39af4313cf | |||
| abfe32569e | |||
| 964981bbfb | |||
| 33faa5f224 | |||
| 99967b450e | |||
| 71a7bf688f | |||
| 302671bc69 | |||
| 4262f469d6 | |||
| b24cb23a83 | |||
| 608f8c6988 | |||
| 2ddfef59d8 | |||
| 6b8cb16bd5 | |||
| 8b89a1112d | |||
| 230ff21220 | |||
| a37c2a8e83 | |||
| 80da8bea74 | |||
| 76f4bf36c7 | |||
| 78a5d907ca | |||
| 0ad60ab292 | |||
| ac443d3b6f | |||
| 48ff0e9f3a | |||
| 675e719a22 | |||
| 0875fae87b | |||
| 668a296142 | |||
| f1892b7049 | |||
| 1bfad507ef | |||
| 72e73bff45 | |||
| e45cf8dd8b | |||
| 79f8e5276a | |||
| 4f97685204 | |||
| 935058582e | |||
| 636b27321b | |||
| 2fdddb3e12 | |||
| f7a97e11e8 | |||
| a56f86965f | |||
| 89674ad718 | |||
| 4e592382dc | |||
| 1fdd2e052b | |||
| 25918830ff | |||
| b47282430e | |||
| ebd3f467c4 | |||
| 0f4ec63a9f | |||
| fdec3b4196 | |||
| b78723ecce | |||
| df0cea225c | |||
| 3b73cca2f5 | |||
| 06d4d5c9e8 |
@@ -10,7 +10,7 @@ jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: '3.13'
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '18.x'
|
||||
default: '22.x'
|
||||
type: string
|
||||
with-front-dependencies-installation:
|
||||
type: boolean
|
||||
|
||||
@@ -58,7 +58,10 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
@@ -99,7 +102,10 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
@@ -28,14 +28,18 @@ jobs:
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep -E "(\bprint\(|\bpprint\()"
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
if: always()
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
@@ -49,7 +53,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
|
||||
run: git log --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -220,7 +224,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.13'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -277,7 +281,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.13'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install gettext (required to compile messages)
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
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
|
||||
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
|
||||
+89
-1
@@ -8,10 +8,89 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(invitations) can delete domain invitations
|
||||
|
||||
## [1.22.2] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(aliases) authorize special domain devnull in alias destinations #1029
|
||||
|
||||
## [1.22.1] - 2026-01-21
|
||||
|
||||
- 🔒️(organization) the first user is not admin #776
|
||||
- 🐛(admin) fix broken alias import #1021
|
||||
|
||||
## [1.22.0] - 2026-01-19
|
||||
|
||||
### Added
|
||||
- ✨(front) create, manage & delete aliases
|
||||
- ✨(domains) alias sorting and admin
|
||||
- ✨(aliases) delete all aliases in one call #1002
|
||||
|
||||
### Fixed
|
||||
- 🔒️(security) upgrade python version to fix vulnerability #1010
|
||||
- 🐛(dimail) ignore oxadmin when importing mailboxes from dimail #986
|
||||
- ✨(aliases) fix deleting single aliases #1002
|
||||
|
||||
### Changed
|
||||
- 🐛(dimail) allow mailboxes and aliases to have the same local part #986
|
||||
|
||||
### Removed
|
||||
- 🔥(plugins) remove CommuneCreation plugin
|
||||
|
||||
## [1.21.0] - 2025-12-05
|
||||
|
||||
- ✨(aliases) import existing aliases from dimail
|
||||
- 🛂(permissions) return 404 to users with no access to domain #985
|
||||
- ✨(aliases) can create, list and delete aliases #974
|
||||
|
||||
## [1.20.0] - 2025-10-22
|
||||
|
||||
- ✨(models) impose uniqueness on display name, to match ox's constraint
|
||||
- 🐛(dimail) catch duplicate displayname error
|
||||
- ✨(mailbox) synchronize password of newly created mailbox with Dimail's
|
||||
|
||||
## [1.19.1] - 2025-09-19
|
||||
|
||||
- 🐛(fix) add enabled update your mailbox
|
||||
|
||||
## [1.19.0] - 2025-09-03
|
||||
|
||||
- ✨(front) add modal update mailboxes #954
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) update mailboxes #934
|
||||
- ✨(api) give update rights to domain viewer on own mailbox #934
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) grab duplicate displayname error #961
|
||||
|
||||
### Changed
|
||||
|
||||
- 💥(sentry) remove `DJANGO_` before Sentry DSN env variable #957
|
||||
|
||||
## [1.18.2] - 2025-07-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(front) fix missing pagination mail domains #950
|
||||
|
||||
## [1.18.1] - 2025-07-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(front) fix missing pagination on mail domains #946
|
||||
|
||||
## [1.18.0] - 2025-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- 🐛(front) fix missing pagination mail domains
|
||||
- 🐛(front) fix button add mail domain
|
||||
- ✨(teams) add matrix webhook for teams #904
|
||||
- ✨(resource-server) add SCIM /Me endpoint #895
|
||||
@@ -411,7 +490,16 @@ 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/suitenumerique/people/compare/v1.18.0...main
|
||||
[unreleased]: https://github.com/suitenumerique/people/compare/v1.22.2...main
|
||||
[1.22.2]: https://github.com/suitenumerique/people/releases/v1.22.2
|
||||
[1.22.1]: https://github.com/suitenumerique/people/releases/v1.22.1
|
||||
[1.22.0]: https://github.com/suitenumerique/people/releases/v1.22.0
|
||||
[1.21.0]: https://github.com/suitenumerique/people/releases/v1.21.0
|
||||
[1.20.0]: https://github.com/suitenumerique/people/releases/v1.20.0
|
||||
[1.19.1]: https://github.com/suitenumerique/people/releases/v1.19.1
|
||||
[1.19.0]: https://github.com/suitenumerique/people/releases/v1.19.0
|
||||
[1.18.2]: https://github.com/suitenumerique/people/releases/v1.18.2
|
||||
[1.18.1]: https://github.com/suitenumerique/people/releases/v1.18.1
|
||||
[1.18.0]: https://github.com/suitenumerique/people/releases/v1.18.0
|
||||
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
|
||||
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
FROM python:3.13.11-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
@@ -23,7 +23,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
FROM node:22 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ bootstrap: \
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev
|
||||
@$(COMPOSE) build dimail
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -123,7 +124,7 @@ run: ## start the wsgi (production) and servers with production Docker images
|
||||
.PHONY: run
|
||||
|
||||
run-dev: ## start the servers in development mode (watch) Docker images
|
||||
@$(COMPOSE) up --force-recreate --detach app-dev frontend-dev celery-dev celery-beat-dev nginx maildev
|
||||
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
|
||||
.PHONY: run-dev
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
|
||||
+2
-4
@@ -46,8 +46,6 @@ services:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
dimail:
|
||||
condition: service_started
|
||||
maildev:
|
||||
condition: service_started
|
||||
redis:
|
||||
@@ -199,7 +197,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
@@ -259,7 +257,7 @@ services:
|
||||
|
||||
dimail:
|
||||
entrypoint: /opt/dimail-api/start-dev.sh
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.2.11
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.5.2
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
|
||||
+27
-23
@@ -17,48 +17,52 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
|
||||
|
||||
This script will ask you for the version you want to release and the kind of release (patch, minor, major). It will then:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
- Create a new branch named: `release/4.18.1`.
|
||||
|
||||
2. Bump the release number for backend and frontend project.
|
||||
- Bump the release number for backend and frontend project.
|
||||
|
||||
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
- Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
- Commit your changes with the following format: 🔖 release emoji, type of release (patch/minor/patch) and release version:
|
||||
|
||||
```text
|
||||
🔖(minor) release version 4.18.1
|
||||
```
|
||||
|
||||
3. Open a pull request ask you to wait for an approval from your peers and merge it.
|
||||
- Triggers Crowdin translation action and open related PR
|
||||
|
||||
4. Ask you to tag and push your commit:
|
||||
- Open release PR. Wait for an approval from your peers and merge it.
|
||||
|
||||
> [!NOTE]
|
||||
> It also open the PR for pre-prod deployment, see following section.
|
||||
|
||||
3. Following release script instructions,
|
||||
|
||||
- merge translation and release pulls requests
|
||||
|
||||
- tag and push your commit:
|
||||
|
||||
```bash
|
||||
git tag v4.18.1 && git push origin tag v4.18.1
|
||||
```
|
||||
|
||||
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
|
||||
This triggers the CI building new Docker images. You can ensure images were successfully built on Docker Hub [here for back-end](https://hub.docker.com/r/lasuite/people-backend/tags) and [here for front-end](https://hub.docker.com/r/lasuite/people-frontend/tags).
|
||||
|
||||
5. Ensure the new [backend](https://hub.docker.com/r/lasuite/people-backend/tags) and [frontend](https://hub.docker.com/r/lasuite/people-frontend/tags) image tags are on Docker Hub.
|
||||
|
||||
6. Create a PR on the [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement) repository to bump the preprod version.
|
||||
# Deploying
|
||||
|
||||
7. The release is now done!
|
||||
## Staging
|
||||
|
||||
# Deploying
|
||||
The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
## Pre-prod and production
|
||||
|
||||
Making a new release doesn't publish it automatically in production.
|
||||
If you used the release script and had permission to push on [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement), a deployment branch has been created. You can skip step 1.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
Otherwise, for manual preprod and for production deployments :
|
||||
1. Bump tag version for both front-end and back-end images in the `.gotmpl` file located in `manifests/<your-product>/env.d/<your-environment>/`,
|
||||
2. Add optional new secrets and variables, if applicable
|
||||
3. Create a pull request
|
||||
4. Submit to approval and merge PR
|
||||
|
||||
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
|
||||
|
||||
To publish the release you just made:
|
||||
|
||||
```bash
|
||||
git tag --force production v4.18.1
|
||||
git push --force origin production
|
||||
```
|
||||
The release is now done! 🎉
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
|
||||
|
||||
@@ -17,8 +17,6 @@ from core.models import (
|
||||
AccountService,
|
||||
Contact,
|
||||
Organization,
|
||||
OrganizationAccess,
|
||||
OrganizationRoleChoices,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -131,15 +129,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
|
||||
user = super().create_user(claims | {"organization": organization})
|
||||
|
||||
if organization_created:
|
||||
# Warning: we may remove this behavior in the near future when we
|
||||
# add a feature to claim the organization ownership.
|
||||
OrganizationAccess.objects.create(
|
||||
organization=organization,
|
||||
user=user,
|
||||
role=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
# Initiate the user's profile
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# ruff: noqa: S311
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,7 @@ class BasePluginAppConfigMixIn:
|
||||
Initialize the hooks registry when the application is ready.
|
||||
This is called by Django when the application is loaded.
|
||||
"""
|
||||
from .registry import registry # pylint: disable=import-outside-toplevel
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .registry import registry # noqa: PLC0415
|
||||
|
||||
registry.register_app(self.name) # pylint: disable=no-member
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
with django_assert_num_queries(4):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -160,7 +160,8 @@ def test_authentication_getter_existing_user_via_email(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(3): # user by email + user by sub + update sub
|
||||
with django_assert_num_queries(5):
|
||||
# 5 = user by email + user by sub + update sub + 2 from django-lasuite
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -390,6 +391,8 @@ def test_authentication_getter_new_user_with_registration_id_new_organization(
|
||||
assert user.organization.domain_list == expected_domain_list
|
||||
assert user.organization.registration_id_list == expected_registration_id_list
|
||||
|
||||
assert models.OrganizationAccess.objects.filter(user=user).exists() is False
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email_update_organization(
|
||||
django_assert_num_queries, monkeypatch
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for the authentication process of the resource server."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
@@ -9,7 +8,7 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.rfc7518.rsa_key import RSAKey
|
||||
from joserfc.jwk import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
from lasuite.oidc_resource_server.authentication import (
|
||||
ResourceServerAuthentication,
|
||||
@@ -84,8 +83,7 @@ def test_resource_server_authentication_class(client, settings):
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
responses.post(
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
@@ -181,23 +179,20 @@ def test_jwt_resource_server_authentication_class( # pylint: disable=unused-arg
|
||||
|
||||
# Mock the JWKS endpoint
|
||||
public_numbers = private_key.public_key().public_numbers()
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
body=json.dumps(
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
"use": "sig",
|
||||
"kid": "1234567890",
|
||||
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
|
||||
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
json={
|
||||
"keys": [
|
||||
{
|
||||
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
"use": "sig",
|
||||
"kid": "1234567890",
|
||||
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
|
||||
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def encrypt_jwt(json_data):
|
||||
@@ -225,8 +220,7 @@ def test_jwt_resource_server_authentication_class( # pylint: disable=unused-arg
|
||||
],
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
responses.post(
|
||||
"https://oidc.example.com/introspect",
|
||||
body=encrypt_jwt(
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_matrix_webhook__search_user_unknown(caplog):
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_empty()["message"]),
|
||||
json=matrix.mock_search_empty()["message"],
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -62,7 +62,7 @@ def test_matrix_webhook__search_multiple_ids(caplog):
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful_multiple(user)["message"]),
|
||||
json=matrix.mock_search_successful_multiple(user)["message"],
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -92,7 +92,7 @@ def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
json=matrix.mock_search_successful(user)["message"],
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
@@ -123,7 +123,7 @@ def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
json=matrix.mock_search_successful(user)["message"],
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
@@ -165,7 +165,7 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
json=matrix.mock_search_successful(user)["message"],
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
@@ -216,7 +216,7 @@ def test_matrix_webhook__override_secret_for_tchap():
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
json=matrix.mock_search_successful(user)["message"],
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
@@ -230,6 +230,9 @@ def test_matrix_webhook__override_secret_for_tchap():
|
||||
headers = responses.calls[0].request.headers
|
||||
assert "TCHAP_TOKEN" in headers["Authorization"]
|
||||
|
||||
# Check correctly inferred base url from room_id
|
||||
assert "matrix.home_server" in responses.calls[0].request.url
|
||||
|
||||
|
||||
## KICK
|
||||
@responses.activate
|
||||
@@ -247,17 +250,17 @@ def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
json=matrix.mock_join_room_successful,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
json=matrix.mock_search_successful(user)["message"],
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/kick"),
|
||||
body=str(matrix.mock_kick_user_not_in_room()["message"]),
|
||||
json=matrix.mock_kick_user_not_in_room()["message"],
|
||||
status=matrix.mock_kick_user_not_in_room()["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
|
||||
@@ -293,7 +296,7 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
json=matrix.mock_search_successful(user)["message"],
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
@@ -342,7 +345,7 @@ def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
json=matrix.mock_search_successful(user)["message"],
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
|
||||
@@ -45,7 +45,7 @@ class MatrixAPIClient:
|
||||
"""Returns room id from webhook url."""
|
||||
room_id = webhook_url.split("/room/")[1]
|
||||
base_url = room_id.split(":")[1]
|
||||
if "tchap.gouv.fr" in webhook_url:
|
||||
if "tchap.gouv.fr" in webhook_url and "i.tchap.gouv.fr" not in webhook_url:
|
||||
base_url = f"matrix.{base_url}"
|
||||
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ NB_OBJECTS = {
|
||||
"users": 1000,
|
||||
"teams": 100,
|
||||
"max_users_per_team": 100,
|
||||
"domains": 20,
|
||||
"domains": 10,
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ def create_oidc_people_idp_client_user():
|
||||
)
|
||||
|
||||
|
||||
def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
def create_demo(stdout): # pylint: disable=too-many-branches too-many-statements too-many-locals # noqa: PLR0912, PLR0915
|
||||
"""
|
||||
Create a database with demo data for developers to work in a realistic environment.
|
||||
The code is engineered to create a huge number of objects fast.
|
||||
@@ -303,6 +303,30 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating mailboxes"):
|
||||
domains_ids = list(
|
||||
mailbox_models.MailDomain.objects.values_list("id", flat=True)
|
||||
)
|
||||
for domain_id in domains_ids:
|
||||
for i in range(random.randint(1, 10)):
|
||||
first_name = fake.first_name()
|
||||
last_name = fake.last_name()
|
||||
local_part = f"{first_name.lower()}.{last_name.lower()}{i}"
|
||||
|
||||
queue.push(
|
||||
mailbox_models.Mailbox(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
local_part=local_part,
|
||||
domain_id=domain_id,
|
||||
secondary_email=f"{local_part}@example.fr",
|
||||
status=random.choice(MailboxStatusChoices.values),
|
||||
dn_email=local_part,
|
||||
)
|
||||
)
|
||||
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating specific users"):
|
||||
# ⚠️ Warning: this users also need to be created in the keycloak
|
||||
# realm.json AND the OIDC setting to fallback on user email
|
||||
@@ -371,17 +395,56 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
|
||||
queue.flush()
|
||||
|
||||
# Enabled domain for 2E2 tests
|
||||
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
|
||||
name="enabled-domain.com",
|
||||
status=MailDomainStatusChoices.ENABLED,
|
||||
support_email="support@enabled-domain.com",
|
||||
)
|
||||
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
|
||||
mailbox_models.MailDomainAccess.objects.get_or_create(
|
||||
domain=enabled_domain,
|
||||
user=domain_owner,
|
||||
role=MailDomainRoleChoices.OWNER,
|
||||
# Enabled domain for 2E2 tests
|
||||
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
|
||||
name="enabled-domain.com",
|
||||
status=MailDomainStatusChoices.ENABLED,
|
||||
support_email="support@enabled-domain.com",
|
||||
)
|
||||
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
|
||||
mailbox_models.MailDomainAccess.objects.get_or_create(
|
||||
domain=enabled_domain,
|
||||
user=domain_owner,
|
||||
role=MailDomainRoleChoices.OWNER,
|
||||
)
|
||||
mailbox_models.MailDomainInvitation.objects.create(
|
||||
issuer=domain_owner,
|
||||
domain=enabled_domain,
|
||||
email="people@people.world",
|
||||
role=MailDomainRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
# Many mailboxes domain
|
||||
many_boxes_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
|
||||
name="many-boxes-domain.com",
|
||||
status=MailDomainStatusChoices.ENABLED,
|
||||
support_email="support@mbd.com",
|
||||
)
|
||||
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
|
||||
mailbox_models.MailDomainAccess.objects.get_or_create(
|
||||
domain=many_boxes_domain,
|
||||
user=domain_owner,
|
||||
role=MailDomainRoleChoices.OWNER,
|
||||
)
|
||||
mailbox_models.MailDomainInvitation.objects.create(
|
||||
issuer=domain_owner,
|
||||
domain=many_boxes_domain,
|
||||
email="people@people.world",
|
||||
role=MailDomainRoleChoices.OWNER,
|
||||
)
|
||||
for _i in range(30):
|
||||
first_name = fake.first_name()
|
||||
last_name = fake.last_name()
|
||||
local_part = f"{first_name.lower()}.{last_name.lower()}"
|
||||
mailbox_models.Mailbox.objects.create(
|
||||
domain=many_boxes_domain,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
local_part=local_part,
|
||||
secondary_email=f"{local_part}@example.fr",
|
||||
status=random.choice(MailboxStatusChoices.values),
|
||||
password=make_password(None), # unusable password
|
||||
dn_email=f"{local_part}@{many_boxes_domain}",
|
||||
)
|
||||
|
||||
# OIDC configuration
|
||||
|
||||
@@ -19,7 +19,7 @@ TEST_NB_OBJECTS = {
|
||||
"users": 100,
|
||||
"teams": 100,
|
||||
"max_users_per_team": 5,
|
||||
"domains": 100,
|
||||
"domains": 10,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,16 +40,19 @@ def test_commands_create_demo(settings):
|
||||
|
||||
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
|
||||
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
|
||||
|
||||
# nb_domains + example.com + enabled + many-boxed-domain
|
||||
assert (
|
||||
mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1 + 1
|
||||
mailbox_models.MailDomain.objects.count()
|
||||
== TEST_NB_OBJECTS["domains"] + 1 + 1 + 1
|
||||
)
|
||||
|
||||
# 3 domain access for each user with domain rights
|
||||
# 3 x 3 domain access for each user with both rights
|
||||
# 1 domain for E2E mail owner user
|
||||
# 2 domains for E2E mail owner user
|
||||
assert (
|
||||
mailbox_models.MailDomainAccess.objects.count()
|
||||
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 1
|
||||
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 2
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-19 15:31+0000\n"
|
||||
"PO-Revision-Date: 2025-06-30 10:05\n"
|
||||
"POT-Creation-Date: 2026-01-23 17:58+0000\n"
|
||||
"PO-Revision-Date: 2026-01-26 11:02\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -45,24 +45,24 @@ msgstr ""
|
||||
msgid "People core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:104
|
||||
#: core/authentication/backends.py:104
|
||||
#: build/lib/core/authentication/backends.py:102
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:124
|
||||
#: core/authentication/backends.py:124
|
||||
#: build/lib/core/authentication/backends.py:122
|
||||
#: core/authentication/backends.py:122
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:181
|
||||
#: build/lib/core/authentication/backends.py:183
|
||||
#: core/authentication/backends.py:181 core/authentication/backends.py:183
|
||||
#: build/lib/core/authentication/backends.py:170
|
||||
#: build/lib/core/authentication/backends.py:172
|
||||
#: core/authentication/backends.py:170 core/authentication/backends.py:172
|
||||
msgid "Invalid authorization header."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:188
|
||||
#: core/authentication/backends.py:188
|
||||
#: build/lib/core/authentication/backends.py:177
|
||||
#: core/authentication/backends.py:177
|
||||
msgid "Invalid api key."
|
||||
msgstr ""
|
||||
|
||||
@@ -149,8 +149,8 @@ msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:250 build/lib/core/models.py:364
|
||||
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
|
||||
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
|
||||
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:53
|
||||
#: build/lib/mailbox_manager/models.py:55 core/models.py:250 core/models.py:364
|
||||
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:55
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
@@ -361,90 +361,101 @@ msgstr ""
|
||||
msgid "Account services"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
|
||||
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
|
||||
msgid "Import emails from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
|
||||
#: build/lib/mailbox_manager/admin.py:32 build/lib/mailbox_manager/admin.py:71
|
||||
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
|
||||
#, python-format
|
||||
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:42 mailbox_manager/admin.py:42
|
||||
#: build/lib/mailbox_manager/admin.py:39 mailbox_manager/admin.py:39
|
||||
#, python-format
|
||||
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:49 mailbox_manager/admin.py:49
|
||||
#: build/lib/mailbox_manager/admin.py:46 build/lib/mailbox_manager/admin.py:91
|
||||
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
|
||||
#, python-format
|
||||
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:54 mailbox_manager/admin.py:54
|
||||
#: build/lib/mailbox_manager/admin.py:51 mailbox_manager/admin.py:51
|
||||
msgid "Import aliases from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:78 mailbox_manager/admin.py:78
|
||||
#, python-format
|
||||
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:96 mailbox_manager/admin.py:96
|
||||
msgid "Check and update status from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:71 mailbox_manager/admin.py:71
|
||||
#: build/lib/mailbox_manager/admin.py:113 mailbox_manager/admin.py:113
|
||||
#, python-format
|
||||
msgid "- %(domain)s with message: %(err)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
|
||||
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
|
||||
msgid "Check domains done with success."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:85 mailbox_manager/admin.py:85
|
||||
#, python-format
|
||||
msgid "Domains updated: %(domains)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
|
||||
msgid "No domain updated."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
|
||||
msgid "Check domain failed for:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
|
||||
#, python-format
|
||||
msgid "Domains disabled are excluded from check: %(domains)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
|
||||
msgid "Fetch domain expected config from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
|
||||
#, python-format
|
||||
msgid "Domain expected config fetched with success for %(domain)s."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
|
||||
#, python-format
|
||||
msgid "Domains updated: %(domains)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:129 mailbox_manager/admin.py:129
|
||||
msgid "No domain updated."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:136 mailbox_manager/admin.py:136
|
||||
msgid "Check domain failed for:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:144 mailbox_manager/admin.py:144
|
||||
#, python-format
|
||||
msgid "Domains disabled are excluded from check: %(domains)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:149 mailbox_manager/admin.py:149
|
||||
msgid "Fetch domain expected config from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:163 mailbox_manager/admin.py:163
|
||||
#, python-format
|
||||
msgid "Domain expected config fetched with success for %(domain)s."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:169 mailbox_manager/admin.py:169
|
||||
#, python-format
|
||||
msgid "Failed to fetch domain expected config for %(domain)s."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:133 mailbox_manager/admin.py:133
|
||||
#: build/lib/mailbox_manager/admin.py:175 mailbox_manager/admin.py:175
|
||||
#, python-format
|
||||
msgid "Domains disabled are excluded from fetch: %(domains)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
|
||||
#: build/lib/mailbox_manager/admin.py:180 mailbox_manager/admin.py:180
|
||||
msgid "Send pending mailboxes to dimail"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
|
||||
#: build/lib/mailbox_manager/admin.py:196 mailbox_manager/admin.py:196
|
||||
#, python-format
|
||||
msgid "Failed to send the following mailboxes : %(mailboxes)s."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
|
||||
#: build/lib/mailbox_manager/admin.py:202 mailbox_manager/admin.py:202
|
||||
#, python-format
|
||||
msgid "Pending mailboxes successfully sent for %(domain)s."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
|
||||
#: build/lib/mailbox_manager/admin.py:208 mailbox_manager/admin.py:208
|
||||
#, python-format
|
||||
msgid "Domains disabled are excluded from : %(domains)s"
|
||||
msgstr ""
|
||||
@@ -476,97 +487,109 @@ msgstr ""
|
||||
msgid "Action required"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
|
||||
#: build/lib/mailbox_manager/models.py:34 mailbox_manager/models.py:34
|
||||
msgid "[La Suite] Your domain is ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
|
||||
#: build/lib/mailbox_manager/models.py:39 mailbox_manager/models.py:39
|
||||
msgid "[La Suite] Your domain requires action"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
|
||||
#: build/lib/mailbox_manager/models.py:44 mailbox_manager/models.py:44
|
||||
msgid "[La Suite] Your domain has failed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
|
||||
#: build/lib/mailbox_manager/models.py:70 mailbox_manager/models.py:70
|
||||
msgid "support email"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
|
||||
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
|
||||
msgid "last check details"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
|
||||
#: build/lib/mailbox_manager/models.py:75 mailbox_manager/models.py:75
|
||||
msgid "A JSON object containing the last health check details"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
|
||||
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
|
||||
msgid "expected config"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
|
||||
#: build/lib/mailbox_manager/models.py:81 mailbox_manager/models.py:81
|
||||
msgid "A JSON object containing the expected config"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
|
||||
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
|
||||
msgid "Mail domain"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
|
||||
#: build/lib/mailbox_manager/models.py:87 mailbox_manager/models.py:87
|
||||
msgid "Mail domains"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
|
||||
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
|
||||
msgid "User/mail domain relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
|
||||
#: build/lib/mailbox_manager/models.py:204 mailbox_manager/models.py:204
|
||||
msgid "User/mail domain relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
|
||||
#: build/lib/mailbox_manager/models.py:277 mailbox_manager/models.py:277
|
||||
msgid "local_part"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
|
||||
#: build/lib/mailbox_manager/models.py:291 mailbox_manager/models.py:291
|
||||
msgid "secondary email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
|
||||
#: build/lib/mailbox_manager/models.py:302 mailbox_manager/models.py:302
|
||||
msgid "email"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
|
||||
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
|
||||
msgid "Mailbox"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
|
||||
#: build/lib/mailbox_manager/models.py:309 mailbox_manager/models.py:309
|
||||
msgid "Mailboxes"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
|
||||
#: build/lib/mailbox_manager/models.py:351 mailbox_manager/models.py:351
|
||||
msgid "You can't create or update a mailbox for a disabled domain."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
|
||||
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
|
||||
msgid "Mail domain invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
|
||||
#: build/lib/mailbox_manager/models.py:409 mailbox_manager/models.py:409
|
||||
msgid "Mail domain invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
|
||||
#: build/lib/mailbox_manager/models.py:421 mailbox_manager/models.py:421
|
||||
msgid "[La Suite] You have been invited to join La Régie"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/utils/dimail.py:268
|
||||
#: mailbox_manager/utils/dimail.py:268
|
||||
#: build/lib/mailbox_manager/models.py:465 mailbox_manager/models.py:465
|
||||
msgid "destination address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:483 mailbox_manager/models.py:483
|
||||
msgid "Alias"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:484 mailbox_manager/models.py:484
|
||||
msgid "Aliases"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/utils/dimail.py:296
|
||||
#: mailbox_manager/utils/dimail.py:296
|
||||
msgid "Your new mailbox information"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/utils/dimail.py:279
|
||||
#: mailbox_manager/utils/dimail.py:279
|
||||
#: build/lib/mailbox_manager/utils/dimail.py:307
|
||||
#: mailbox_manager/utils/dimail.py:307
|
||||
msgid "Your password has been updated"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-19 15:31+0000\n"
|
||||
"PO-Revision-Date: 2025-06-30 10:05\n"
|
||||
"POT-Creation-Date: 2026-01-23 17:58+0000\n"
|
||||
"PO-Revision-Date: 2026-01-26 11:02\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -45,24 +45,24 @@ msgstr "Les plugins de post-création ont été exécutés pour les organisation
|
||||
msgid "People core application"
|
||||
msgstr "Application cœur de People"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:104
|
||||
#: core/authentication/backends.py:104
|
||||
#: build/lib/core/authentication/backends.py:102
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:124
|
||||
#: core/authentication/backends.py:124
|
||||
#: build/lib/core/authentication/backends.py:122
|
||||
#: core/authentication/backends.py:122
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:181
|
||||
#: build/lib/core/authentication/backends.py:183
|
||||
#: core/authentication/backends.py:181 core/authentication/backends.py:183
|
||||
#: build/lib/core/authentication/backends.py:170
|
||||
#: build/lib/core/authentication/backends.py:172
|
||||
#: core/authentication/backends.py:170 core/authentication/backends.py:172
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "En-tête d'autorisation invalide."
|
||||
|
||||
#: build/lib/core/authentication/backends.py:188
|
||||
#: core/authentication/backends.py:188
|
||||
#: build/lib/core/authentication/backends.py:177
|
||||
#: core/authentication/backends.py:177
|
||||
msgid "Invalid api key."
|
||||
msgstr "Clé API invalide."
|
||||
|
||||
@@ -149,8 +149,8 @@ msgstr "contacts"
|
||||
|
||||
#: build/lib/core/models.py:250 build/lib/core/models.py:364
|
||||
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
|
||||
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
|
||||
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:53
|
||||
#: build/lib/mailbox_manager/models.py:55 core/models.py:250 core/models.py:364
|
||||
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:55
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
@@ -270,11 +270,11 @@ msgstr "Cet utilisateur est déjà dans cette organisation."
|
||||
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
msgid "external_id"
|
||||
msgstr ""
|
||||
msgstr "ID externe"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Team external UUID for synchronization with external systems"
|
||||
msgstr ""
|
||||
msgstr "UUID externe de l'équipe pour la synchronisation avec des systèmes externes"
|
||||
|
||||
#: build/lib/core/models.py:787 core/models.py:787
|
||||
msgid "is visible for all SP"
|
||||
@@ -361,90 +361,101 @@ msgstr "Compte de service"
|
||||
msgid "Account services"
|
||||
msgstr "Comptes de service"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
|
||||
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
|
||||
msgid "Import emails from dimail"
|
||||
msgstr "Importer les emails depuis dimail"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
|
||||
#: build/lib/mailbox_manager/admin.py:32 build/lib/mailbox_manager/admin.py:71
|
||||
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
|
||||
#, python-format
|
||||
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
|
||||
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:42 mailbox_manager/admin.py:42
|
||||
#: build/lib/mailbox_manager/admin.py:39 mailbox_manager/admin.py:39
|
||||
#, python-format
|
||||
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
|
||||
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:49 mailbox_manager/admin.py:49
|
||||
#: build/lib/mailbox_manager/admin.py:46 build/lib/mailbox_manager/admin.py:91
|
||||
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
|
||||
#, python-format
|
||||
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
|
||||
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:54 mailbox_manager/admin.py:54
|
||||
#: build/lib/mailbox_manager/admin.py:51 mailbox_manager/admin.py:51
|
||||
msgid "Import aliases from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:78 mailbox_manager/admin.py:78
|
||||
#, python-format
|
||||
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:96 mailbox_manager/admin.py:96
|
||||
msgid "Check and update status from dimail"
|
||||
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:71 mailbox_manager/admin.py:71
|
||||
#: build/lib/mailbox_manager/admin.py:113 mailbox_manager/admin.py:113
|
||||
#, python-format
|
||||
msgid "- %(domain)s with message: %(err)s"
|
||||
msgstr "- %(domain)s avec le message : %(err)s"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
|
||||
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
|
||||
msgid "Check domains done with success."
|
||||
msgstr "Vérification des domaines effectuée avec succès."
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:85 mailbox_manager/admin.py:85
|
||||
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
|
||||
#, python-format
|
||||
msgid "Domains updated: %(domains)s"
|
||||
msgstr "Domaines mis à jour : %(domains)s"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
|
||||
#: build/lib/mailbox_manager/admin.py:129 mailbox_manager/admin.py:129
|
||||
msgid "No domain updated."
|
||||
msgstr "Aucun domaine mis à jour."
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
|
||||
#: build/lib/mailbox_manager/admin.py:136 mailbox_manager/admin.py:136
|
||||
msgid "Check domain failed for:"
|
||||
msgstr "La vérification du domaine a échoué pour :"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
|
||||
#: build/lib/mailbox_manager/admin.py:144 mailbox_manager/admin.py:144
|
||||
#, python-format
|
||||
msgid "Domains disabled are excluded from check: %(domains)s"
|
||||
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
|
||||
#: build/lib/mailbox_manager/admin.py:149 mailbox_manager/admin.py:149
|
||||
msgid "Fetch domain expected config from dimail"
|
||||
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
|
||||
#: build/lib/mailbox_manager/admin.py:163 mailbox_manager/admin.py:163
|
||||
#, python-format
|
||||
msgid "Domain expected config fetched with success for %(domain)s."
|
||||
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
|
||||
#: build/lib/mailbox_manager/admin.py:169 mailbox_manager/admin.py:169
|
||||
#, python-format
|
||||
msgid "Failed to fetch domain expected config for %(domain)s."
|
||||
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:133 mailbox_manager/admin.py:133
|
||||
#: build/lib/mailbox_manager/admin.py:175 mailbox_manager/admin.py:175
|
||||
#, python-format
|
||||
msgid "Domains disabled are excluded from fetch: %(domains)s"
|
||||
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
|
||||
#: build/lib/mailbox_manager/admin.py:180 mailbox_manager/admin.py:180
|
||||
msgid "Send pending mailboxes to dimail"
|
||||
msgstr "Envoyer les adresses mail en attente à dimail"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
|
||||
#: build/lib/mailbox_manager/admin.py:196 mailbox_manager/admin.py:196
|
||||
#, python-format
|
||||
msgid "Failed to send the following mailboxes : %(mailboxes)s."
|
||||
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
|
||||
#: build/lib/mailbox_manager/admin.py:202 mailbox_manager/admin.py:202
|
||||
#, python-format
|
||||
msgid "Pending mailboxes successfully sent for %(domain)s."
|
||||
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
|
||||
#: build/lib/mailbox_manager/admin.py:208 mailbox_manager/admin.py:208
|
||||
#, python-format
|
||||
msgid "Domains disabled are excluded from : %(domains)s"
|
||||
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
|
||||
@@ -476,97 +487,109 @@ msgstr "Désactivé"
|
||||
msgid "Action required"
|
||||
msgstr "Action requise"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
|
||||
#: build/lib/mailbox_manager/models.py:34 mailbox_manager/models.py:34
|
||||
msgid "[La Suite] Your domain is ready"
|
||||
msgstr "[La Suite] Votre domaine est prêt"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
|
||||
#: build/lib/mailbox_manager/models.py:39 mailbox_manager/models.py:39
|
||||
msgid "[La Suite] Your domain requires action"
|
||||
msgstr "[La Suite] Des actions sont requises sur votre domaine"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
|
||||
#: build/lib/mailbox_manager/models.py:44 mailbox_manager/models.py:44
|
||||
msgid "[La Suite] Your domain has failed"
|
||||
msgstr "[La Suite] Votre domaine est en erreur"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
|
||||
#: build/lib/mailbox_manager/models.py:70 mailbox_manager/models.py:70
|
||||
msgid "support email"
|
||||
msgstr "adresse email du support"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
|
||||
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
|
||||
msgid "last check details"
|
||||
msgstr "détails de la dernière vérification"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
|
||||
#: build/lib/mailbox_manager/models.py:75 mailbox_manager/models.py:75
|
||||
msgid "A JSON object containing the last health check details"
|
||||
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
|
||||
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
|
||||
msgid "expected config"
|
||||
msgstr "configuration attendue"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
|
||||
#: build/lib/mailbox_manager/models.py:81 mailbox_manager/models.py:81
|
||||
msgid "A JSON object containing the expected config"
|
||||
msgstr "Un objet JSON contenant la configuration attendue"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
|
||||
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
|
||||
msgid "Mail domain"
|
||||
msgstr "Domaine de messagerie"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
|
||||
#: build/lib/mailbox_manager/models.py:87 mailbox_manager/models.py:87
|
||||
msgid "Mail domains"
|
||||
msgstr "Domaines de messagerie"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
|
||||
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
|
||||
msgid "User/mail domain relation"
|
||||
msgstr "Relation utilisateur/domaine de messagerie"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
|
||||
#: build/lib/mailbox_manager/models.py:204 mailbox_manager/models.py:204
|
||||
msgid "User/mail domain relations"
|
||||
msgstr "Relations utilisateur/domaine de messagerie"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
|
||||
#: build/lib/mailbox_manager/models.py:277 mailbox_manager/models.py:277
|
||||
msgid "local_part"
|
||||
msgstr "local_part"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
|
||||
#: build/lib/mailbox_manager/models.py:291 mailbox_manager/models.py:291
|
||||
msgid "secondary email address"
|
||||
msgstr "adresse email secondaire"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
|
||||
#: build/lib/mailbox_manager/models.py:302 mailbox_manager/models.py:302
|
||||
msgid "email"
|
||||
msgstr "email"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
|
||||
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
|
||||
msgid "Mailbox"
|
||||
msgstr "Boîte mail"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
|
||||
#: build/lib/mailbox_manager/models.py:309 mailbox_manager/models.py:309
|
||||
msgid "Mailboxes"
|
||||
msgstr "Boîtes mail"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
|
||||
#: build/lib/mailbox_manager/models.py:351 mailbox_manager/models.py:351
|
||||
msgid "You can't create or update a mailbox for a disabled domain."
|
||||
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
|
||||
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
|
||||
msgid "Mail domain invitation"
|
||||
msgstr "Invitation au domaine de messagerie"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
|
||||
#: build/lib/mailbox_manager/models.py:409 mailbox_manager/models.py:409
|
||||
msgid "Mail domain invitations"
|
||||
msgstr "Invitations au domaine de messagerie"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
|
||||
#: build/lib/mailbox_manager/models.py:421 mailbox_manager/models.py:421
|
||||
msgid "[La Suite] You have been invited to join La Régie"
|
||||
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
|
||||
|
||||
#: build/lib/mailbox_manager/utils/dimail.py:268
|
||||
#: mailbox_manager/utils/dimail.py:268
|
||||
#: build/lib/mailbox_manager/models.py:465 mailbox_manager/models.py:465
|
||||
msgid "destination address"
|
||||
msgstr "adresse de destination"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:483 mailbox_manager/models.py:483
|
||||
msgid "Alias"
|
||||
msgstr "Alias"
|
||||
|
||||
#: build/lib/mailbox_manager/models.py:484 mailbox_manager/models.py:484
|
||||
msgid "Aliases"
|
||||
msgstr "Alias"
|
||||
|
||||
#: build/lib/mailbox_manager/utils/dimail.py:296
|
||||
#: mailbox_manager/utils/dimail.py:296
|
||||
msgid "Your new mailbox information"
|
||||
msgstr "Informations sur votre nouvelle boîte mail"
|
||||
|
||||
#: build/lib/mailbox_manager/utils/dimail.py:279
|
||||
#: mailbox_manager/utils/dimail.py:279
|
||||
#: build/lib/mailbox_manager/utils/dimail.py:307
|
||||
#: mailbox_manager/utils/dimail.py:307
|
||||
msgid "Your password has been updated"
|
||||
msgstr "Votre mot de passe a été mis à jour"
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ from requests import exceptions
|
||||
from mailbox_manager import enums, models
|
||||
from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
|
||||
# Prevent Ruff complaining about mark_safe below
|
||||
# ruff: noqa: S308
|
||||
|
||||
|
||||
@admin.action(description=_("Import emails from dimail"))
|
||||
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
@@ -51,6 +48,51 @@ def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disabl
|
||||
)
|
||||
|
||||
|
||||
@admin.action(description=_("Import aliases from dimail"))
|
||||
def sync_aliases_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""
|
||||
Admin action to import existing aliases from dimail.
|
||||
Checks alias is not a duplicate and that usernames don't clash with existing mailboxes.
|
||||
"""
|
||||
excluded_domains = []
|
||||
|
||||
client = DimailAPIClient()
|
||||
|
||||
for domain in queryset:
|
||||
if domain.status != enums.MailDomainStatusChoices.ENABLED:
|
||||
excluded_domains.append(domain.name)
|
||||
continue
|
||||
|
||||
try:
|
||||
imported_aliases = client.import_aliases(domain)
|
||||
except exceptions.HTTPError as err:
|
||||
messages.error(
|
||||
request,
|
||||
_("Synchronisation failed for %(domain)s with message: %(err)s")
|
||||
% {"domain": domain.name, "err": err},
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
_(
|
||||
"Synchronisation succeed for %(domain)s.\
|
||||
Imported %(count_imported)s aliases: %(aliases)s"
|
||||
)
|
||||
% {
|
||||
"domain": domain.name,
|
||||
"count_imported": len(imported_aliases),
|
||||
"aliases": ", ".join(imported_aliases),
|
||||
},
|
||||
)
|
||||
|
||||
if excluded_domains:
|
||||
messages.warning(
|
||||
request,
|
||||
_("Sync require enabled domains. Excluded domains: %(domains)s")
|
||||
% {"domains": ", ".join(excluded_domains)},
|
||||
)
|
||||
|
||||
|
||||
@admin.action(description=_("Check and update status from dimail"))
|
||||
def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Admin action to check domain health with dimail and update domain status."""
|
||||
@@ -194,6 +236,7 @@ class MailDomainAdmin(admin.ModelAdmin):
|
||||
inlines = (UserMailDomainAccessInline,)
|
||||
actions = (
|
||||
sync_mailboxes_from_dimail,
|
||||
sync_aliases_from_dimail,
|
||||
fetch_domain_status_from_dimail,
|
||||
fetch_domain_expected_config_from_dimail,
|
||||
send_pending_mailboxes,
|
||||
@@ -207,8 +250,8 @@ class MailboxAdmin(UserAdmin):
|
||||
|
||||
list_display = ("__str__", "domain", "status", "updated_at")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("local_part", "domain__name")
|
||||
readonly_fields = ["updated_at", "local_part", "domain"]
|
||||
search_fields = ("local_part", "domain__name", "first_name", "last_name")
|
||||
readonly_fields = ["updated_at"]
|
||||
|
||||
fieldsets = None
|
||||
add_fieldsets = (
|
||||
@@ -267,3 +310,13 @@ class MailDomainInvitationAdmin(admin.ModelAdmin):
|
||||
def is_expired(self, obj):
|
||||
"""Return the expiration date of the invitation."""
|
||||
return obj.is_expired
|
||||
|
||||
|
||||
@admin.register(models.Alias)
|
||||
class AliasAdmin(admin.ModelAdmin):
|
||||
"""Admin for alias model."""
|
||||
|
||||
list_display = ("local_part", "domain", "destination", "updated_at")
|
||||
list_filter = ("domain",)
|
||||
search_fields = ("local_part", "domain__name", "destination")
|
||||
readonly_fields = ["updated_at"]
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from logging import getLogger
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core import exceptions as django_exceptions
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework import exceptions, serializers
|
||||
@@ -29,7 +31,6 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
"secondary_email",
|
||||
"status",
|
||||
]
|
||||
# everything is actually read-only as we do not allow update for now
|
||||
read_only_fields = ["id", "status"]
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -45,12 +46,21 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
"password": make_password(None), # generate an unusable password
|
||||
}
|
||||
)
|
||||
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
|
||||
client = DimailAPIClient()
|
||||
|
||||
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
|
||||
# send new mailbox request to dimail
|
||||
response = client.create_mailbox(mailbox, self.context["request"].user.sub)
|
||||
client = DimailAPIClient()
|
||||
try:
|
||||
response = client.create_mailbox(
|
||||
mailbox, self.context["request"].user.sub
|
||||
)
|
||||
except django_exceptions.ValidationError as exc:
|
||||
mailbox.delete()
|
||||
raise exc
|
||||
|
||||
mailbox.status = enums.MailDomainStatusChoices.ENABLED
|
||||
mailbox_data = response.json()
|
||||
mailbox.set_password(mailbox_data["password"])
|
||||
mailbox.save()
|
||||
|
||||
if mailbox.secondary_email:
|
||||
@@ -67,10 +77,25 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
mailbox,
|
||||
)
|
||||
|
||||
# actually save mailbox on our database
|
||||
return mailbox
|
||||
|
||||
|
||||
class MailboxUpdateSerializer(MailboxSerializer):
|
||||
"""A more restrictive serializer when updating mailboxes"""
|
||||
|
||||
class Meta:
|
||||
model = models.Mailbox
|
||||
fields = [
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"local_part",
|
||||
"secondary_email",
|
||||
"status",
|
||||
]
|
||||
read_only_fields = ("id", "local_part", "status")
|
||||
|
||||
|
||||
class MailDomainSerializer(serializers.ModelSerializer):
|
||||
"""Serialize mail domain."""
|
||||
|
||||
@@ -203,23 +228,10 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
"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."
|
||||
)
|
||||
# we're sure that an access exists. otherwise it would have failed at permissions.
|
||||
access = authenticated_user.mail_domain_accesses.get(
|
||||
domain__slug=domain_slug
|
||||
)
|
||||
|
||||
# only an owner can set an owner role to another user
|
||||
if (
|
||||
@@ -277,11 +289,41 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer):
|
||||
) from exc
|
||||
|
||||
domain = models.MailDomain.objects.get(slug=domain_slug)
|
||||
if not domain.get_abilities(user)["manage_accesses"]:
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage invitations for this domain."
|
||||
)
|
||||
|
||||
attrs["domain"] = domain
|
||||
attrs["issuer"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class AliasSerializer(serializers.ModelSerializer):
|
||||
"""Serialize aliases."""
|
||||
|
||||
domain = MailDomainSerializer(default="")
|
||||
|
||||
class Meta:
|
||||
model = models.Alias
|
||||
fields = [
|
||||
"id",
|
||||
"local_part",
|
||||
"destination",
|
||||
"domain",
|
||||
]
|
||||
read_only_fields = ["id", "domain"]
|
||||
|
||||
def validate_domain(self, value): # pylint: disable=unused-argument
|
||||
"""Forcefully set domain field to url domain."""
|
||||
return get_object_or_404(models.MailDomain, slug=self.context["domain_slug"])
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Override create function to fire a request to dimail on alias creation.
|
||||
"""
|
||||
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
|
||||
alias = models.Alias(**validated_data)
|
||||
|
||||
# send new alias request to dimail
|
||||
client = DimailAPIClient()
|
||||
client.create_alias(alias, self.context["request"].user.sub)
|
||||
return super().create(validated_data)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""API endpoints"""
|
||||
|
||||
from django.db.models import Q, Subquery
|
||||
from django.http import Http404
|
||||
|
||||
from rest_framework import exceptions, filters, mixins, viewsets
|
||||
from rest_framework import exceptions, filters, mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -40,7 +41,10 @@ class MailDomainViewSet(
|
||||
Fetch domain status and expected config from dimail.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
permission_classes = [
|
||||
permissions.DomainPermission,
|
||||
permissions.DomainResourcePermission,
|
||||
]
|
||||
serializer_class = serializers.MailDomainSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at", "name"]
|
||||
@@ -112,7 +116,7 @@ class MailDomainAccessViewSet(
|
||||
Delete targeted domain access
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.MailDomainAccessRolePermission]
|
||||
permission_classes = [permissions.DomainResourcePermission]
|
||||
serializer_class = serializers.MailDomainAccessSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["role", "user__email", "user__name"]
|
||||
@@ -228,9 +232,11 @@ class MailDomainAccessViewSet(
|
||||
|
||||
|
||||
class MailBoxViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
):
|
||||
"""MailBox ViewSet
|
||||
|
||||
@@ -252,9 +258,15 @@ class MailBoxViewSet(
|
||||
|
||||
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/reset/
|
||||
Send a request to mail-provider to reset password.
|
||||
|
||||
PUT /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
|
||||
Send a request to update mailbox. Cannot modify domain or local_part.
|
||||
|
||||
PATCH /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
|
||||
Send a request to partially update mailbox. Cannot modify domain or local_part.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.MailBoxPermission]
|
||||
permission_classes = [permissions.DomainResourcePermission]
|
||||
serializer_class = serializers.MailboxSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering = ["-created_at"]
|
||||
@@ -267,6 +279,32 @@ class MailBoxViewSet(
|
||||
return self.queryset.filter(domain__slug=domain_slug)
|
||||
return self.queryset
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["domain_slug"] = self.kwargs["domain_slug"]
|
||||
return context
|
||||
|
||||
def get_permissions(self):
|
||||
"""Add a specific permission for domain viewers to update their own mailbox."""
|
||||
if self.action in ["list", "retrieve"]:
|
||||
permission_classes = [permissions.DomainPermission]
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
permission_classes = [
|
||||
permissions.DomainResourcePermission
|
||||
| permissions.IsMailboxOwnerPermission
|
||||
]
|
||||
else:
|
||||
return super().get_permissions()
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Chooses list or detail serializer according to the action."""
|
||||
if self.action in {"update", "partial_update"}:
|
||||
return serializers.MailboxUpdateSerializer
|
||||
return self.serializer_class
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create new mailbox."""
|
||||
domain_slug = self.kwargs.get("domain_slug", "")
|
||||
@@ -310,6 +348,7 @@ class MailDomainInvitationViewset(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to domain management.
|
||||
@@ -327,10 +366,13 @@ class MailDomainInvitationViewset(
|
||||
|
||||
PUT / PATCH : Not permitted. Instead of updating your invitation,
|
||||
delete and create a new one.
|
||||
|
||||
DELETE /api/<version>/mail-domains/<domain_slug>/invitations/:<invitation_id>/
|
||||
Delete targeted invitation
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
permission_classes = [permissions.DomainResourcePermission]
|
||||
queryset = (
|
||||
models.MailDomainInvitation.objects.all()
|
||||
.select_related("domain")
|
||||
@@ -367,3 +409,102 @@ class MailDomainInvitationViewset(
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class AliasViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for aliases.
|
||||
|
||||
GET /api/<version>/mail-domains/<domain_slug>/aliases/
|
||||
Return list of aliases related to that domain
|
||||
|
||||
POST /api/<version>/mail-domains/<domain_slug>/aliases/ with expected data:
|
||||
- local_part: str
|
||||
- destination: str
|
||||
Return a newly created alias
|
||||
|
||||
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/<alias_pk>/
|
||||
Delete targeted alias
|
||||
|
||||
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/?local_part=<local_part>/
|
||||
Delete all aliases of targeted local_part
|
||||
"""
|
||||
|
||||
lookup_field = "pk"
|
||||
permission_classes = [permissions.DomainResourcePermission]
|
||||
serializer_class = serializers.AliasSerializer
|
||||
queryset = models.Alias.objects.all().select_related("domain")
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["local_part"]
|
||||
ordering = ["local_part"]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["domain_slug"] = self.kwargs["domain_slug"]
|
||||
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 == "list":
|
||||
# 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")
|
||||
|
||||
queryset = (
|
||||
queryset
|
||||
# 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)).distinct()
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""
|
||||
Override destroy method to delete specific alias and send request to dimail.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
self.perform_destroy(instance)
|
||||
|
||||
client = DimailAPIClient()
|
||||
dimail_response = client.delete_alias(instance)
|
||||
|
||||
if dimail_response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
return Response(
|
||||
"Domain out of sync with mailbox provider, please contact our support.",
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=["DELETE"], detail=False)
|
||||
def delete(self, request, *args, **kwargs):
|
||||
"""Bulk delete aliases. Filtering is required and accepted filter is local_part."""
|
||||
|
||||
if "local_part" not in self.request.query_params:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
local_part = self.request.query_params["local_part"]
|
||||
queryset = self.get_queryset().filter(
|
||||
local_part=local_part
|
||||
) # Manually call get_queryset to filter by domain and role
|
||||
if not queryset:
|
||||
raise Http404("No Alias matches the given query.")
|
||||
|
||||
# view is bounded to a domain, fetch is from the queryset to spare a dedicated DB request"
|
||||
domain_name = queryset[0].domain.name
|
||||
queryset.delete()
|
||||
|
||||
client = DimailAPIClient()
|
||||
client.delete_multiple_alias(local_part, domain_name)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
"""Permission handlers for the People mailbox manager app."""
|
||||
|
||||
from core.api import permissions as core_permissions
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core.api.permissions import IsAuthenticated
|
||||
|
||||
from mailbox_manager import models
|
||||
|
||||
|
||||
class AccessPermission(core_permissions.IsAuthenticated):
|
||||
class DomainPermission(IsAuthenticated):
|
||||
"""Permission class to manage mailboxes and aliases for a mail domain"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission based on domain."""
|
||||
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
if not view.kwargs.get("domain_slug"):
|
||||
return True
|
||||
|
||||
domain = get_object_or_404(
|
||||
models.MailDomain,
|
||||
slug=view.kwargs.get("domain_slug", ""),
|
||||
accesses__user=request.user,
|
||||
)
|
||||
|
||||
abilities = domain.get_abilities(request.user)
|
||||
if request.method.lower() == "delete":
|
||||
return abilities.get("manage_accesses", False)
|
||||
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class DomainResourcePermission(DomainPermission):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
@@ -14,20 +43,15 @@ class AccessPermission(core_permissions.IsAuthenticated):
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class MailBoxPermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class to manage mailboxes for a mail domain"""
|
||||
class IsMailboxOwnerPermission(permissions.BasePermission):
|
||||
"""Authorize update for domain viewers on their own mailbox."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission based on domain."""
|
||||
"""This permission is specifically about updates"""
|
||||
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"""
|
||||
return abilities["get"]
|
||||
|
||||
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)
|
||||
"""If the user is trying to update their own mailbox."""
|
||||
return obj.get_email() == request.user.email
|
||||
|
||||
@@ -15,4 +15,5 @@ class MailboxManagerConfig(AppConfig):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
import mailbox_manager.signals # pylint: disable=import-outside-toplevel, unused-import
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
import mailbox_manager.signals # noqa: PLC0415
|
||||
|
||||
@@ -98,3 +98,14 @@ class MailDomainInvitationFactory(factory.django.DjangoModelFactory):
|
||||
[role[0] for role in enums.MailDomainRoleChoices.choices]
|
||||
)
|
||||
issuer = factory.SubFactory(core_factories.UserFactory)
|
||||
|
||||
|
||||
class AliasFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create aliases."""
|
||||
|
||||
class Meta:
|
||||
model = models.Alias
|
||||
|
||||
domain = factory.SubFactory(MailDomainEnabledFactory)
|
||||
local_part = factory.Faker("word")
|
||||
destination = factory.Faker("email")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-08 12:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0025_alter_mailbox_secondary_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='mailbox',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='mailbox',
|
||||
constraint=models.UniqueConstraint(fields=('local_part', 'domain'), name='unique_username'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='mailbox',
|
||||
constraint=models.UniqueConstraint(fields=('first_name', 'last_name', 'domain'), name='unique_ox_display_name'),
|
||||
),
|
||||
]
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 15:49
|
||||
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0026_alter_mailbox_unique_together_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='mailbox',
|
||||
name='unique_ox_display_name',
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='mailbox',
|
||||
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('first_name'), django.db.models.functions.text.Lower('last_name'), models.F('domain'), name='unique_ox_display_name', violation_error_message='Mailbox with this First name, Last name and Domain already exists.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 12:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0027_remove_mailbox_unique_ox_display_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Alias',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('local_part', models.CharField(max_length=100)),
|
||||
('destination', models.CharField(max_length=254, validators=[django.core.validators.EmailValidator(allowlist=['localhost', 'devnull'])], verbose_name='destination address')),
|
||||
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='mailbox_manager.maildomain')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Alias',
|
||||
'verbose_name_plural': 'Aliases',
|
||||
'db_table': 'people_aliases',
|
||||
'ordering': ['-created_at'],
|
||||
'unique_together': {('local_part', 'destination')},
|
||||
},
|
||||
),
|
||||
]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-17 17:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0028_alias'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='alias',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='alias',
|
||||
constraint=models.UniqueConstraint(fields=('domain', 'local_part', 'destination'), name='no_duplicate'),
|
||||
),
|
||||
]
|
||||
@@ -9,7 +9,9 @@ from django.conf import settings
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db import models
|
||||
from django.db.models.functions import Lower
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
@@ -88,6 +90,8 @@ class MailDomain(BaseModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
objects = models.Manager()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save function to compute the slug."""
|
||||
self.slug = self.get_slug()
|
||||
@@ -303,7 +307,23 @@ class Mailbox(AbstractBaseUser, BaseModel):
|
||||
db_table = "people_mail_box"
|
||||
verbose_name = _("Mailbox")
|
||||
verbose_name_plural = _("Mailboxes")
|
||||
unique_together = ("local_part", "domain")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["local_part", "domain"], name="unique_username"
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
Lower("first_name"),
|
||||
Lower("last_name"),
|
||||
"domain",
|
||||
name="unique_ox_display_name",
|
||||
violation_error_message="Mailbox with this First name, \
|
||||
Last name and Domain already exists.",
|
||||
),
|
||||
# Display name in OpenXChange must be unique
|
||||
# To avoid sending failing requests to dimail,
|
||||
# we impose uniqueness here too
|
||||
# And compare to lowercase to enforce case-insensitive uniqueness
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
@@ -341,6 +361,25 @@ class Mailbox(AbstractBaseUser, BaseModel):
|
||||
"""Return the email address of the mailbox."""
|
||||
return f"{self.local_part}@{self.domain.name}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user."""
|
||||
role = user.mail_domain_accesses.get(domain=self.domain).role
|
||||
|
||||
is_owner_or_admin = role in [
|
||||
MailDomainRoleChoices.OWNER,
|
||||
MailDomainRoleChoices.ADMIN,
|
||||
]
|
||||
|
||||
is_self = self.get_email() == user.email
|
||||
|
||||
return {
|
||||
"get": bool(role),
|
||||
"post": is_owner_or_admin,
|
||||
"patch": is_owner_or_admin or is_self,
|
||||
"put": is_owner_or_admin or is_self,
|
||||
"delete": False,
|
||||
}
|
||||
|
||||
|
||||
class MailDomainInvitation(BaseInvitation):
|
||||
"""User invitation to mail domains."""
|
||||
@@ -416,3 +455,62 @@ class MailDomainInvitation(BaseInvitation):
|
||||
"patch": False,
|
||||
"put": False,
|
||||
}
|
||||
|
||||
|
||||
class Alias(BaseModel):
|
||||
"""Model for aliases."""
|
||||
|
||||
local_part = models.CharField(max_length=100, blank=False)
|
||||
destination = models.CharField(
|
||||
_("destination address"),
|
||||
max_length=254,
|
||||
null=False,
|
||||
blank=False,
|
||||
validators=[
|
||||
EmailValidator(allowlist=["localhost", "devnull"]),
|
||||
],
|
||||
)
|
||||
domain = models.ForeignKey(
|
||||
MailDomain,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="aliases",
|
||||
null=False,
|
||||
blank=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_aliases"
|
||||
verbose_name = _("Alias")
|
||||
verbose_name_plural = _("Aliases")
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["domain", "local_part", "destination"], name="no_duplicate"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.local_part} to {self.destination}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user. Admin and owners can
|
||||
edit aliases, but also viewer if the alias points to their email."""
|
||||
try:
|
||||
role = user.mail_domain_accesses.get(domain=self.domain).role
|
||||
except (MailDomainAccess.DoesNotExist, IndexError):
|
||||
role = None
|
||||
|
||||
is_owner_or_admin = role in [
|
||||
MailDomainRoleChoices.OWNER,
|
||||
MailDomainRoleChoices.ADMIN,
|
||||
]
|
||||
|
||||
is_self = self.destination == user.email
|
||||
|
||||
return {
|
||||
"get": bool(role),
|
||||
"post": is_owner_or_admin,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
"delete": is_owner_or_admin or is_self,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for aliases API endpoint in People's app mailbox_manager.
|
||||
Focus on "bulk delete" action.
|
||||
"""
|
||||
# pylint: disable=W0613
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_aliases_bulk_delete__anonymous_get_401():
|
||||
"""Anonymous user should not be able to bulk delete."""
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert models.Alias.objects.count() == 3
|
||||
|
||||
|
||||
def test_api_aliases_bulk_delete__no_access_get_404():
|
||||
"""User with no access to domain should not be able to bulk delete."""
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(core_factories.UserFactory())
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.Alias.objects.count() == 3
|
||||
|
||||
|
||||
def test_api_aliases_bulk_delete__viewer_get_403():
|
||||
"""Viewer user should not be able to bulk delete."""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
|
||||
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=access.domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/?local_part={alias_.local_part}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert models.Alias.objects.count() == 3
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_aliases_bulk_delete__administrators_allowed_all_destination(
|
||||
dimail_token_ok,
|
||||
):
|
||||
"""
|
||||
Administrators of a domain should be allowed to bulk delete all aliases
|
||||
of a given local_part.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
|
||||
)
|
||||
alias_ = factories.AliasFactory(domain=mail_domain)
|
||||
factories.AliasFactory.create_batch(
|
||||
2, domain=mail_domain, local_part=alias_.local_part
|
||||
)
|
||||
|
||||
# additional aliases that shouldn't be affected
|
||||
factories.AliasFactory.create_batch(
|
||||
2, domain=mail_domain, destination=alias_.destination
|
||||
)
|
||||
factories.AliasFactory(
|
||||
local_part=alias_.local_part,
|
||||
destination=alias_.destination,
|
||||
)
|
||||
|
||||
# Mock dimail response
|
||||
responses.delete(
|
||||
re.compile(r".*/aliases/"),
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert models.Alias.objects.count() == 3
|
||||
assert not models.Alias.objects.filter(
|
||||
domain=mail_domain, local_part=alias_.local_part
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_aliases_bulk_delete__no_local_part_bad_request():
|
||||
"""Filtering by local part is mandatory when bulk deleting aliases."""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
|
||||
)
|
||||
alias_ = factories.AliasFactory(domain=mail_domain)
|
||||
factories.AliasFactory.create_batch(
|
||||
2, domain=mail_domain, local_part=alias_.local_part
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert models.Alias.objects.count() == 3
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Tests for aliases API endpoint.
|
||||
Focus on "create" action.
|
||||
"""
|
||||
# pylint: disable=W0613
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_aliases_create__anonymous():
|
||||
"""Anonymous user should not create aliases"""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
|
||||
{"whatever": "this should not be updated"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert not models.Alias.objects.exists()
|
||||
|
||||
|
||||
def test_api_aliases_create__no_access_forbidden_not_found():
|
||||
"""Unrelated user not having domain permission should not create aliases."""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(core_factories.UserFactory())
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
|
||||
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert not models.Alias.objects.exists()
|
||||
|
||||
|
||||
def test_api_aliases_create__viewer_forbidden():
|
||||
"""Domain viewers should not create aliases."""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
access = factories.MailDomainAccessFactory(role="viewer", domain=domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
|
||||
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert not models.Alias.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_aliases_create__duplicate_forbidden():
|
||||
"""Cannot create alias if existing alias same domain + local part + destination."""
|
||||
access = factories.MailDomainAccessFactory(
|
||||
role="owner", domain=factories.MailDomainEnabledFactory()
|
||||
)
|
||||
|
||||
existing_alias = factories.AliasFactory(domain=access.domain)
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
|
||||
{
|
||||
"local_part": existing_alias.local_part,
|
||||
"destination": existing_alias.destination,
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert models.Alias.objects.filter(domain=access.domain).count() == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_aliases_create__async_alias_bad_request(dimail_token_ok):
|
||||
"""
|
||||
If People fall out of sync with dimail, return a clear error if alias cannot be created
|
||||
because it already exists on dimail.
|
||||
"""
|
||||
access = factories.MailDomainAccessFactory(
|
||||
role="owner", domain=factories.MailDomainEnabledFactory()
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
# Mock dimail response
|
||||
responses.post(
|
||||
re.compile(r".*/aliases/"),
|
||||
json={"detail": "Alias already exists"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
|
||||
{
|
||||
"local_part": "already_existing_alias",
|
||||
"destination": "someone@outsidedomain.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {
|
||||
"NON_FIELD_ERRORS": [
|
||||
"Alias already exists. Your domain is out of sync, please contact our support."
|
||||
]
|
||||
}
|
||||
assert not models.Alias.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN],
|
||||
)
|
||||
def test_api_aliases_create__admins_ok(role, dimail_token_ok):
|
||||
"""Domain admins should be able to create aliases."""
|
||||
access = factories.MailDomainAccessFactory(role=role)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
# Prepare responses
|
||||
# token response in fixtures
|
||||
responses.post(
|
||||
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
|
||||
json={
|
||||
"username": "contact",
|
||||
"domain": access.domain.name,
|
||||
"destination": "someone@outsidedomain.com",
|
||||
"allow_to_send": True,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
|
||||
{"local_part": "contact", "destination": "someone@outsidedomain.com"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
alias = models.Alias.objects.get()
|
||||
assert alias.local_part == "contact"
|
||||
assert alias.destination == "someone@outsidedomain.com"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_aliases_create__existing_mailbox_ok(dimail_token_ok):
|
||||
"""Can create alias even if local_part is already used by a mailbox."""
|
||||
access = factories.MailDomainAccessFactory(
|
||||
role="owner", domain=factories.MailDomainEnabledFactory()
|
||||
)
|
||||
mailbox = factories.MailboxFactory(domain=access.domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
responses.post(
|
||||
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
|
||||
json={
|
||||
"username": mailbox.local_part,
|
||||
"domain": access.domain.name,
|
||||
"destination": "someone@outsidedomain.com",
|
||||
"allow_to_send": False,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
|
||||
{"local_part": mailbox.local_part, "destination": "someone@outsidedomain.com"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert models.Alias.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_aliases_create__devnull_destination_ok(dimail_token_ok):
|
||||
"""Can create alias where destination is devnull@devnull."""
|
||||
access = factories.MailDomainAccessFactory(
|
||||
role="owner", domain=factories.MailDomainEnabledFactory()
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
responses.post(
|
||||
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
|
||||
json={
|
||||
"username": "spammy-address",
|
||||
"domain": access.domain.name,
|
||||
"destination": "devnull@devnull",
|
||||
"allow_to_send": False,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
|
||||
{"local_part": "spammy-address", "destination": "devnull@devnull"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert models.Alias.objects.exists()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Tests for aliases API endpoint in People's app mailbox_manager.
|
||||
Focus on "delete" action.
|
||||
"""
|
||||
# pylint: disable=W0613
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_aliases_delete__anonymous():
|
||||
"""Anonymous user should not be able to delete aliases."""
|
||||
alias_ = factories.AliasFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert models.Alias.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_aliases_delete__no_access_forbidden_not_found():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete an alias in a
|
||||
mail domain to which they are not related.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
alias_ = factories.AliasFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.Alias.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_aliases_delete__viewer_forbidden():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete aliases 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)]
|
||||
)
|
||||
alias_ = factories.AliasFactory(domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert models.Alias.objects.count() == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
|
||||
"""
|
||||
Administrators of a mail domain should be allowed to delete aliases.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
|
||||
)
|
||||
alias_ = factories.AliasFactory(domain=mail_domain)
|
||||
factories.AliasFactory.create_batch(
|
||||
2, domain=mail_domain, local_part=alias_.local_part
|
||||
)
|
||||
|
||||
# additional aliases that shouldn't be affected
|
||||
factories.AliasFactory.create_batch(
|
||||
2, domain=mail_domain, destination=alias_.destination
|
||||
)
|
||||
factories.AliasFactory(
|
||||
local_part=alias_.local_part,
|
||||
destination=alias_.destination,
|
||||
)
|
||||
|
||||
# Mock dimail response
|
||||
responses.delete(
|
||||
re.compile(r".*/aliases/"),
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert models.Alias.objects.count() == 5
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
|
||||
"""
|
||||
Should return a clear message when dimail returns 404 not found.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
|
||||
)
|
||||
alias_ = factories.AliasFactory(domain=mail_domain)
|
||||
# Mock dimail response
|
||||
responses.delete(
|
||||
re.compile(r".*/aliases/"),
|
||||
json={"detail": "Not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.json()
|
||||
== "Domain out of sync with mailbox provider, please contact our support."
|
||||
)
|
||||
assert not models.Alias.objects.exists()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Tests for aliases API endpoint in People's app mailbox_manager.
|
||||
Focus on "list" action.
|
||||
"""
|
||||
|
||||
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_aliases_list__anonymous():
|
||||
"""Anonymous user should not be able to list aliases"""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
factories.AliasFactory.create_batch(3, domain=domain)
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_aliases_list__no_access_forbidden_not_found():
|
||||
"""User authenticated but not having domain permission should not list aliases."""
|
||||
factories.MailDomainAccessFactory() # access to another domain
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
factories.AliasFactory.create_batch(3, domain=domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(core_factories.UserFactory())
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.VIEWER,
|
||||
],
|
||||
)
|
||||
def test_api_aliases_list__authorized_ok(role):
|
||||
"""Domain viewers and admins should be able to list aliases."""
|
||||
access = factories.MailDomainAccessFactory(role=role)
|
||||
factories.AliasFactory.create_batch(2, local_part="support", domain=access.domain)
|
||||
factories.AliasFactory.create_batch(3, domain=access.domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["results"] == sorted(
|
||||
response.json()["results"], key=lambda x: x["local_part"]
|
||||
)
|
||||
assert response.json()["count"] == 5
|
||||
+4
-4
@@ -35,7 +35,7 @@ def test_api_domain_invitations__create__anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_domain_invitations__create__authenticated_outsider():
|
||||
def test_api_domain_invitations__create__no_access_forbidden_not_found():
|
||||
"""Users should not be permitted to send domain management invitations
|
||||
for a domain they don't manage."""
|
||||
user = core_factories.UserFactory()
|
||||
@@ -52,7 +52,7 @@ def test_api_domain_invitations__create__authenticated_outsider():
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -86,7 +86,7 @@ def test_api_domain_invitations__admin_should_create_invites(role):
|
||||
assert email.subject == "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
|
||||
|
||||
|
||||
def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
|
||||
def test_api_domain_invitations__no_access_forbidden_not_found():
|
||||
"""
|
||||
Domain viewers should not be able to invite new domain managers.
|
||||
"""
|
||||
@@ -110,7 +110,7 @@ def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage invitations for this domain."
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Tests for MailDomainInvitation API endpoint in People's app mailbox_manager.
|
||||
Focus on "delete" action.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from mailbox_manager import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_domain_invitations__delete__anonymous():
|
||||
"""Anonymous users should not be able to delete invitations."""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
invitation = factories.MailDomainInvitationFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.MailDomainInvitation.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_domain_invitations__delete__no_access_not_found():
|
||||
"""Users should not be permitted to delete invitations
|
||||
on domains they don't manage."""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
invitation = factories.MailDomainInvitationFactory()
|
||||
|
||||
other_access = factories.MailDomainAccessFactory(role="owner") # unrelated access
|
||||
client = APIClient()
|
||||
client.force_login(other_access.user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.MailDomainInvitation.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_domain_invitations__delete__viewers_forbidden():
|
||||
"""Domain viewers should not be permitted to delete invitations."""
|
||||
access = factories.MailDomainAccessFactory(role="viewer")
|
||||
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert models.MailDomainInvitation.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_domain_invitations__delete_admins_ok(role):
|
||||
"""Domain owners and admins should be able to delete invitations."""
|
||||
access = factories.MailDomainAccessFactory(role=role)
|
||||
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not models.MailDomainInvitation.objects.exists()
|
||||
+3
-3
@@ -26,7 +26,7 @@ def test_api_domain_invitations__anonymous_user_should_not_retrieve_invitations(
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations():
|
||||
def test_api_domain_invitations__no_access_forbidden_not_found():
|
||||
"""
|
||||
Authenticated unrelated users should not be able to retrieve invitations.
|
||||
"""
|
||||
@@ -40,8 +40,8 @@ def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations(
|
||||
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 0
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No MailDomain matches the given query."}
|
||||
|
||||
|
||||
def test_api_domain_invitations__domain_managers_should_list_invitations():
|
||||
|
||||
@@ -41,17 +41,14 @@ def test_api_mail_domains__create_name_unique():
|
||||
"""
|
||||
Creating domain should raise an error if already existing name.
|
||||
"""
|
||||
factories.MailDomainFactory(name="existing_domain.com")
|
||||
user = core_factories.UserFactory()
|
||||
existing_domain = factories.MailDomainFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(core_factories.UserFactory())
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": "existing_domain.com",
|
||||
},
|
||||
{"name": existing_domain.name},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -71,8 +68,7 @@ def test_api_mail_domains__create_authenticated():
|
||||
|
||||
domain_name = "test.domain.fr"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
responses.post(
|
||||
re.compile(r".*/domains/"),
|
||||
body=str(
|
||||
{
|
||||
@@ -84,17 +80,15 @@ def test_api_mail_domains__create_authenticated():
|
||||
)
|
||||
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
|
||||
body_content_domain1["name"] = domain_name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain_name}/check/"),
|
||||
body=json.dumps(body_content_domain1),
|
||||
json=body_content_domain1,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain_name}/spec/"),
|
||||
body=json.dumps(DOMAIN_SPEC),
|
||||
json=DOMAIN_SPEC,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -158,30 +152,25 @@ def test_api_mail_domains__create_dimail_domain(caplog):
|
||||
client.force_login(user)
|
||||
domain_name = "test.fr"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
responses.post(
|
||||
re.compile(r".*/domains/"),
|
||||
body=str(
|
||||
{
|
||||
"name": domain_name,
|
||||
}
|
||||
),
|
||||
json={
|
||||
"name": domain_name,
|
||||
}
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
body_content_domain1 = CHECK_DOMAIN_OK.copy()
|
||||
body_content_domain1["name"] = domain_name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain_name}/check/"),
|
||||
body=json.dumps(body_content_domain1),
|
||||
json=body_content_domain1,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain_name}/spec/"),
|
||||
body=json.dumps(DOMAIN_SPEC),
|
||||
json=DOMAIN_SPEC,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -216,8 +205,7 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
|
||||
"status_code": status.HTTP_409_CONFLICT,
|
||||
"detail": "Domain already exists",
|
||||
}
|
||||
responses.add(
|
||||
responses.POST,
|
||||
responses.post(
|
||||
re.compile(r".*/domains/"),
|
||||
body=str({"detail": dimail_error["detail"]}),
|
||||
status=dimail_error["status_code"],
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "fetch" action.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
@@ -82,7 +81,7 @@ def test_api_mail_domains__fetch_from_dimail__viewer():
|
||||
],
|
||||
)
|
||||
@responses.activate
|
||||
def test_api_mail_domains__fetch_from_dimail(role):
|
||||
def test_api_mail_domains__fetch_from_dimail_admin_successful(role):
|
||||
"""
|
||||
Authenticated users should be allowed to fetch a domain
|
||||
from dimail if they are an owner or admin.
|
||||
@@ -105,14 +104,12 @@ def test_api_mail_domains__fetch_from_dimail(role):
|
||||
assert domain.expected_config is None
|
||||
assert domain.last_check_details is None
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
json=dimail_fixtures.CHECK_DOMAIN_OK,
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/spec/"),
|
||||
json=dimail_fixtures.DOMAIN_SPEC,
|
||||
status=200,
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_api_mail_domains__retrieve_authenticated_unrelated():
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_mail_domains__retrieve_authenticated_related():
|
||||
def test_api_mail_domains__retrieve_authenticated_related_successful():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a domain
|
||||
to which they have access.
|
||||
|
||||
+5
-5
@@ -35,7 +35,7 @@ def test_api_mail_domain__accesses_create_anonymous():
|
||||
assert models.MailDomainAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_create_authenticated_unrelated():
|
||||
def test_api_mail_domain__accesses_create_no_access_forbidden_not_found():
|
||||
"""
|
||||
Authenticated users should not be allowed to create domain accesses for a domain to
|
||||
which they are not related.
|
||||
@@ -56,14 +56,14 @@ def test_api_mail_domain__accesses_create_authenticated_unrelated():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this domain."
|
||||
"detail": "No MailDomain matches the given query.",
|
||||
}
|
||||
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_create_authenticated_viewer():
|
||||
def test_api_mail_domain__accesses_create_viewer_forbidden():
|
||||
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
@@ -85,7 +85,7 @@ def test_api_mail_domain__accesses_create_authenticated_viewer():
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this domain."
|
||||
"detail": "You do not have permission to perform this action.",
|
||||
}
|
||||
|
||||
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
+7
-7
@@ -13,7 +13,7 @@ from mailbox_manager import enums, factories, models
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_anonymous():
|
||||
def test_api_domain_accesses_delete__anonymous_forbidden():
|
||||
"""Anonymous users should not be allowed to destroy a mail domain access."""
|
||||
access = factories.MailDomainAccessFactory()
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_api_mail_domain__accesses_delete_anonymous():
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_authenticated():
|
||||
def test_api_domain_accesses_delete__unrelated_notfound():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a mail domain access for a
|
||||
mail domain to which they are not related.
|
||||
@@ -39,11 +39,11 @@ def test_api_mail_domain__accesses_delete_authenticated():
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_viewer():
|
||||
def test_api_domain_accesses_delete__viewer_forbidden():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a mail domain access for a
|
||||
mail domain in which they are a simple viewer.
|
||||
@@ -65,7 +65,7 @@ def test_api_mail_domain__accesses_delete_viewer():
|
||||
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_administrators():
|
||||
def test_api_domain_accesses_delete__administrators_successful():
|
||||
"""
|
||||
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
|
||||
"""
|
||||
@@ -89,7 +89,7 @@ def test_api_mail_domain__accesses_delete_administrators():
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_owners():
|
||||
def test_api_domain_accesses_delete__owners_successful():
|
||||
"""
|
||||
An owner should be able to delete the mail domain access of another user including
|
||||
a owner access.
|
||||
@@ -114,7 +114,7 @@ def test_api_mail_domain__accesses_delete_owners():
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_owners_last_owner():
|
||||
def test_api_domain_accesses_delete__last_owner_forbidden():
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a mail domain
|
||||
"""
|
||||
|
||||
+9
-9
@@ -27,8 +27,7 @@ def test_api_mail_domain__accesses_list_anonymous():
|
||||
|
||||
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 with no access should not be allowed to list domain accesses.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
@@ -43,12 +42,9 @@ def test_api_mail_domain__accesses_list_authenticated_unrelated():
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
"detail": "No MailDomain matches the given query.",
|
||||
}
|
||||
|
||||
|
||||
@@ -178,8 +174,12 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
|
||||
# related users :
|
||||
# - query retrieving logged-in user for user_role annotation
|
||||
# - count from pagination
|
||||
|
||||
# - filter on slug and domain on permission (to return 404 instead of 403 when no access)
|
||||
# - count ?
|
||||
|
||||
# - distinct from viewset
|
||||
with django_assert_num_queries(3):
|
||||
with django_assert_num_queries(5):
|
||||
client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
@@ -189,7 +189,7 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
|
||||
factories.MailDomainAccessFactory(domain=mail_domain)
|
||||
|
||||
# num queries should still be the same
|
||||
with django_assert_num_queries(3):
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
|
||||
+2
-4
@@ -43,7 +43,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
|
||||
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."}
|
||||
assert response.json() == {"detail": "No MailDomain 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 [
|
||||
@@ -55,9 +55,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"detail": "No MailDomainAccess matches the given query."
|
||||
}
|
||||
assert response.json() == {"detail": "No MailDomain matches the given query."}
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_retrieve_authenticated_related():
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ def test_api_mail_domain__accesses_update_anonymous():
|
||||
assert access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_authenticated_unrelated():
|
||||
def test_api_mail_domain__accesses_update_no_access_forbidden_not_found():
|
||||
"""
|
||||
An authenticated user should not be allowed to update a mail domain access
|
||||
for a mail_domain to which they are not related.
|
||||
@@ -42,7 +42,7 @@ def test_api_mail_domain__accesses_update_authenticated_unrelated():
|
||||
{"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
access.refresh_from_db()
|
||||
assert access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
|
||||
+2
-4
@@ -30,9 +30,7 @@ def test_api_mail_domain__available_users_anonymous():
|
||||
|
||||
|
||||
def test_api_mail_domain__available_users_forbidden():
|
||||
"""Authenticated user without accesses on maildomain should not be able to see available
|
||||
users.
|
||||
"""
|
||||
"""Users with no access should not be able to list available users."""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
@@ -40,7 +38,7 @@ def test_api_mail_domain__available_users_forbidden():
|
||||
|
||||
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ def test_api_mailboxes__disable_anonymous_forbidden():
|
||||
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
|
||||
|
||||
|
||||
def test_api_mailboxes__disable_authenticated_failure():
|
||||
def test_api_mailboxes__disable_no_access_forbidden_not_found():
|
||||
"""Authenticated users should not be able to disable mailbox
|
||||
without specific role on mail domain."""
|
||||
user = core_factories.UserFactory()
|
||||
@@ -39,7 +39,7 @@ def test_api_mailboxes__disable_authenticated_failure():
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/disable/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
|
||||
|
||||
|
||||
|
||||
@@ -26,9 +26,8 @@ def test_api_mailboxes__enable_anonymous_forbidden():
|
||||
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
|
||||
|
||||
|
||||
def test_api_mailboxes__enable_authenticated_failure():
|
||||
"""Authenticated users should not be able to enable mailbox
|
||||
without specific role on mail domain."""
|
||||
def test_api_mailboxes__enable_unrelated_not_found():
|
||||
"""Unrelated users should not be able to enable mailbox."""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -39,7 +38,7 @@ def test_api_mailboxes__enable_authenticated_failure():
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/enable/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
|
||||
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ def test_api_mailboxes__list_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_mailboxes__list_authenticated():
|
||||
"""Authenticated users should not be able to list mailboxes"""
|
||||
def test_api_mailboxes__list_unrelated_not_found():
|
||||
"""Unrelated users should not be able to list mailboxes"""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -36,10 +36,8 @@ def test_api_mailboxes__list_authenticated():
|
||||
factories.MailboxFactory.create_batch(2, domain=mail_domain)
|
||||
|
||||
response = client.get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No MailDomain matches the given query."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Unit tests for the mailbox API
|
||||
"""
|
||||
|
||||
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_mailboxes_patch__anonymous_forbidden():
|
||||
"""Anonymous users should not be able to update a mailbox."""
|
||||
mailbox = factories.MailboxFactory()
|
||||
saved_secondary = mailbox.secondary_email
|
||||
|
||||
response = APIClient().patch(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
{"secondary_email": "updated@example.com"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
mailbox.refresh_from_db()
|
||||
assert mailbox.secondary_email == saved_secondary
|
||||
|
||||
|
||||
def test_api_mailboxes_patch__no_access_forbidden_not_found():
|
||||
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
|
||||
mailbox = factories.MailboxFactory()
|
||||
saved_secondary = mailbox.secondary_email
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(core_factories.UserFactory())
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.local_part}/",
|
||||
{"secondary_email": "updated@example.com"},
|
||||
format="json",
|
||||
)
|
||||
# permission denied at domain level
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mailbox.refresh_from_db()
|
||||
assert mailbox.secondary_email == saved_secondary
|
||||
|
||||
|
||||
def test_api_mailboxes_patch__viewer_forbidden():
|
||||
"""User having viewer access on a domain should not be able to update
|
||||
anything on a mailbox that's not theirs."""
|
||||
viewer = core_factories.UserFactory()
|
||||
mailbox = factories.MailboxFactory()
|
||||
factories.MailDomainAccessFactory(
|
||||
user=viewer,
|
||||
domain=mailbox.domain,
|
||||
role=enums.MailDomainRoleChoices.VIEWER,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(viewer)
|
||||
|
||||
# should not be able to update any field
|
||||
# we only try one field as 403 is global in our implementation
|
||||
old_value = mailbox.secondary_email
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
{"secondary_email": "updated@example.com"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mailbox.refresh_from_db()
|
||||
assert mailbox.secondary_email == old_value
|
||||
|
||||
|
||||
def test_api_mailboxes_patch__unauthorized_no_mailbox():
|
||||
"""No mailbox returns a 404."""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.ADMIN)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/nonexistent_mailbox_pk/",
|
||||
{"secondary_email": "updated@example.com"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
# permission denied at domain level
|
||||
# the existence of the mailbox is not checked
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
# UPDATABLE FIELDS : SECONDARY EMAIL FIRST NAME AND LAST NAME
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_mailboxes_patch__admins_can_update_mailboxes(role):
|
||||
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
|
||||
mailbox = factories.MailboxFactory()
|
||||
user = core_factories.UserFactory()
|
||||
factories.MailDomainAccessFactory(
|
||||
user=user,
|
||||
domain=mailbox.domain,
|
||||
role=role,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
valid_new_values = {
|
||||
"secondary_email": "updated_mail@validformat.com",
|
||||
"first_name": "Marsha",
|
||||
"last_name": "Johnson",
|
||||
}
|
||||
for field_name in ["first_name", "last_name", "secondary_email"]:
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
{field_name: valid_new_values[field_name]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mailbox.refresh_from_db()
|
||||
assert getattr(mailbox, field_name) == valid_new_values[field_name]
|
||||
|
||||
|
||||
def test_api_mailboxes_patch__viewer_can_update_own_mailbox():
|
||||
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
|
||||
mailbox = factories.MailboxFactory()
|
||||
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
|
||||
factories.MailDomainAccessFactory(
|
||||
user=user,
|
||||
domain=mailbox.domain,
|
||||
role=enums.MailDomainRoleChoices.VIEWER,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
valid_new_values = {
|
||||
"secondary_email": "updated_mail@validformat.com",
|
||||
"first_name": "Marsha",
|
||||
"last_name": "Johnson",
|
||||
}
|
||||
for field_name in ["first_name", "last_name", "secondary_email"]:
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
{field_name: valid_new_values[field_name]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mailbox.refresh_from_db()
|
||||
assert getattr(mailbox, field_name) == valid_new_values[field_name]
|
||||
|
||||
|
||||
# DOMAIN AND LOCAL PART
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["viewer", "owner", "administrator"],
|
||||
)
|
||||
def test_api_mailboxes_patch__no_updating_domain_or_local_part(role):
|
||||
"""Domain and local parts should not be updated."""
|
||||
mailbox = factories.MailboxFactory()
|
||||
user = core_factories.UserFactory()
|
||||
if role == "viewer":
|
||||
# viewer has similar privileges as admins on own mailbox
|
||||
user = core_factories.UserFactory(
|
||||
email=f"{mailbox.local_part}@{mailbox.domain}"
|
||||
)
|
||||
access = factories.MailDomainAccessFactory(
|
||||
user=user,
|
||||
domain=mailbox.domain,
|
||||
role=role,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_domain = factories.MailDomainEnabledFactory()
|
||||
local_part = mailbox.local_part
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
{"local_part": "new.local.part", "domain": other_domain.name},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK # a 400 would be better
|
||||
mailbox.refresh_from_db()
|
||||
assert mailbox.local_part == local_part
|
||||
assert mailbox.domain == access.domain
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Unit tests for the mailbox API
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# PUT expects all the object's updatable fields.
|
||||
# We'll use this dict on all the following tests
|
||||
NEW_VALUES = {
|
||||
"secondary_email": "marsha.p@social.us",
|
||||
"first_name": "Marsha",
|
||||
"last_name": "Johnson",
|
||||
}
|
||||
|
||||
|
||||
def test_api_mailboxes_put__anonymous_forbidden():
|
||||
"""Anonymous users should not be able to update a mailbox."""
|
||||
mailbox = factories.MailboxFactory()
|
||||
saved_secondary = mailbox.secondary_email
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
NEW_VALUES,
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
mailbox.refresh_from_db()
|
||||
assert mailbox.secondary_email == saved_secondary
|
||||
|
||||
|
||||
def test_api_mailboxes_put__no_access_forbidden_not_found():
|
||||
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
|
||||
client = APIClient()
|
||||
client.force_login(core_factories.UserFactory())
|
||||
|
||||
mailbox = factories.MailboxFactory()
|
||||
saved_secondary = mailbox.secondary_email
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
NEW_VALUES,
|
||||
format="json",
|
||||
)
|
||||
|
||||
# permission denied at domain level
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mailbox.refresh_from_db()
|
||||
assert mailbox.secondary_email == saved_secondary
|
||||
|
||||
|
||||
def test_api_mailboxes_put__unauthorized_no_mailbox():
|
||||
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
|
||||
client = APIClient()
|
||||
client.force_login(core_factories.UserFactory())
|
||||
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/mailboxes/nonexistent_mailbox_pk/",
|
||||
NEW_VALUES,
|
||||
format="json",
|
||||
)
|
||||
|
||||
# permission denied at domain level
|
||||
# the existence of the mailbox is not checked
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_mailboxes_put__viewer_forbidden():
|
||||
"""User having viewer access on a domain should not be able to update
|
||||
anything on a mailbox that's not theirs."""
|
||||
viewer = core_factories.UserFactory()
|
||||
mailbox = factories.MailboxFactory()
|
||||
factories.MailDomainAccessFactory(
|
||||
user=viewer,
|
||||
domain=mailbox.domain,
|
||||
role=enums.MailDomainRoleChoices.VIEWER,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(viewer)
|
||||
|
||||
# should not be able to update any field
|
||||
# we only try one field as 403 is global in our implementation
|
||||
old_value = mailbox.secondary_email
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
NEW_VALUES,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mailbox.refresh_from_db()
|
||||
assert mailbox.secondary_email == old_value
|
||||
|
||||
|
||||
# UPDATABLE FIELDS : SECONDARY EMAIL, FIRST NAME AND LAST NAME
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_mailboxes_put__admins_can_update_mailboxes(role):
|
||||
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
|
||||
mailbox = factories.MailboxFactory()
|
||||
user = core_factories.UserFactory()
|
||||
factories.MailDomainAccessFactory(
|
||||
user=user,
|
||||
domain=mailbox.domain,
|
||||
role=role,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
NEW_VALUES,
|
||||
format="json",
|
||||
)
|
||||
result = response.json()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mailbox.refresh_from_db()
|
||||
assert result["first_name"] == NEW_VALUES["first_name"]
|
||||
assert result["last_name"] == NEW_VALUES["last_name"]
|
||||
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
|
||||
|
||||
|
||||
def test_api_mailboxes_put__viewer_can_update_own_mailbox():
|
||||
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
|
||||
mailbox = factories.MailboxFactory()
|
||||
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
|
||||
factories.MailDomainAccessFactory(
|
||||
user=user,
|
||||
domain=mailbox.domain,
|
||||
role=enums.MailDomainRoleChoices.VIEWER,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
NEW_VALUES,
|
||||
format="json",
|
||||
)
|
||||
result = response.json()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mailbox.refresh_from_db()
|
||||
assert result["first_name"] == NEW_VALUES["first_name"]
|
||||
assert result["last_name"] == NEW_VALUES["last_name"]
|
||||
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
|
||||
|
||||
|
||||
# DOMAIN AND LOCAL PART
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["viewer", "owner", "administrator"],
|
||||
)
|
||||
def test_api_mailboxes_put__no_updating_domain_or_local_part(role):
|
||||
"""Domain and local parts should not be updated."""
|
||||
mailbox = factories.MailboxFactory()
|
||||
user = core_factories.UserFactory()
|
||||
if role == "viewer":
|
||||
# viewer has similar privileges as admins on own mailbox
|
||||
user = core_factories.UserFactory(
|
||||
email=f"{mailbox.local_part}@{mailbox.domain}"
|
||||
)
|
||||
access = factories.MailDomainAccessFactory(
|
||||
user=user,
|
||||
domain=mailbox.domain,
|
||||
role=role,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_domain = factories.MailDomainEnabledFactory()
|
||||
old_local_part = mailbox.local_part
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
|
||||
{**NEW_VALUES, "local_part": "new.local.part", "domain": other_domain.name},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK # This should not be a 200
|
||||
mailbox.refresh_from_db()
|
||||
assert mailbox.local_part == old_local_part
|
||||
assert mailbox.domain == access.domain
|
||||
+7
-23
@@ -28,7 +28,7 @@ def test_api_mailboxes__reset_password_anonymous_unauthorized():
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_mailboxes__reset_password_unrelated_forbidden():
|
||||
def test_api_mailboxes__reset_password_no_access_forbidden_not_found():
|
||||
"""Authenticated users not managing the domain
|
||||
should not be able to reset its mailboxes password."""
|
||||
user = core_factories.UserFactory()
|
||||
@@ -41,9 +41,9 @@ def test_api_mailboxes__reset_password_unrelated_forbidden():
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
"detail": "No MailDomain matches the given query.",
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ Please add a valid secondary email before trying again."
|
||||
],
|
||||
)
|
||||
@responses.activate
|
||||
def test_api_mailboxes__reset_password_admin_successful(role):
|
||||
def test_api_mailboxes__reset_password_admin_successful(role, dimail_token_ok):
|
||||
"""Owner and admin users should be able to reset password on mailboxes.
|
||||
New password should be sent to secondary email."""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
@@ -123,15 +123,7 @@ def test_api_mailboxes__reset_password_admin_successful(role):
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
dimail_url = settings.MAIL_PROVISIONING_API_URL
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{dimail_url}/token/",
|
||||
body=dimail.TOKEN_OK,
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
responses.post(
|
||||
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
|
||||
body=dimail.response_mailbox_created(str(mailbox)),
|
||||
status=200,
|
||||
@@ -161,7 +153,7 @@ def test_api_mailboxes__reset_password_non_existing():
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_mailboxes__reset_password_connexion_failed():
|
||||
def test_api_mailboxes__reset_password_connexion_failed(dimail_token_ok):
|
||||
"""
|
||||
No mail is sent when password reset failed because of connexion error.
|
||||
"""
|
||||
@@ -173,16 +165,8 @@ def test_api_mailboxes__reset_password_connexion_failed():
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
dimail_url = settings.MAIL_PROVISIONING_API_URL
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{dimail_url}/token/",
|
||||
body=dimail.TOKEN_OK,
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
responses.post(
|
||||
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
|
||||
body=ConnectionError(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Unit tests for the mailbox API
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mailboxes__retrieve_anonymous_forbidden():
|
||||
"""Anonymous users should not be able to retrieve a new mailbox via the API."""
|
||||
mailbox = factories.MailboxFactory()
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_mailboxes__retrieve_no_access_forbidden_not_found():
|
||||
"""Authenticated but unauthorized users should not be able to
|
||||
retrieve mailbox. Response should be indistinguisable from normal 404"""
|
||||
client = APIClient()
|
||||
client.force_login(core_factories.UserFactory())
|
||||
|
||||
mailbox = factories.MailboxFactory()
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# even if they have a mailbox on this domain
|
||||
mailbox = factories.MailboxFactory()
|
||||
client.force_login(core_factories.UserFactory(email=str(mailbox)))
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_mailboxes__retrieve_authorized_ok():
|
||||
"""Authorized users should be able to retrieve mailboxes."""
|
||||
|
||||
access = factories.MailDomainAccessFactory()
|
||||
mailbox = factories.MailboxFactory(domain=access.domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(mailbox.id),
|
||||
"first_name": mailbox.first_name,
|
||||
"last_name": mailbox.last_name,
|
||||
"local_part": mailbox.local_part,
|
||||
"secondary_email": mailbox.secondary_email,
|
||||
"status": mailbox.status,
|
||||
}
|
||||
@@ -48,8 +48,7 @@ def test_fetch_domain_status():
|
||||
(domain_failed, body_content_ok3),
|
||||
]:
|
||||
# mock dimail API
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_content),
|
||||
status=status.HTTP_200_OK,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Fixtures for mailbox manager tests
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
|
||||
from mailbox_manager import factories
|
||||
|
||||
|
||||
## DIMAIL RESPONSES
|
||||
@pytest.fixture(name="dimail_token_ok")
|
||||
def fixture_dimail_token_ok():
|
||||
"""Mock dimail response when /token/ endpoit is given valid credentials."""
|
||||
responses.get(
|
||||
re.compile(r".*/token/"),
|
||||
json={"access_token": "token", "token_type": "bearer"},
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="mailbox_data")
|
||||
def fixture_mailbox_data():
|
||||
"""Provides valid mailbox data for tests."""
|
||||
example = factories.MailboxFactory.build()
|
||||
return {
|
||||
"first_name": example.first_name,
|
||||
"last_name": example.last_name,
|
||||
"local_part": example.local_part,
|
||||
"secondary_email": example.secondary_email,
|
||||
}
|
||||
+1
-11
@@ -3,9 +3,8 @@
|
||||
|
||||
import json
|
||||
|
||||
|
||||
## USERS
|
||||
|
||||
|
||||
def response_user_created(user_sub):
|
||||
"""mimic dimail response upon successful user creation."""
|
||||
return json.dumps(
|
||||
@@ -19,7 +18,6 @@ def response_user_created(user_sub):
|
||||
|
||||
|
||||
## DOMAINS
|
||||
|
||||
CHECK_DOMAIN_BROKEN = {
|
||||
"name": "example.fr",
|
||||
"state": "broken",
|
||||
@@ -289,13 +287,7 @@ DOMAIN_SPEC = [
|
||||
]
|
||||
|
||||
|
||||
## TOKEN
|
||||
|
||||
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
|
||||
|
||||
## ALLOWS
|
||||
|
||||
|
||||
def response_allows_created(user_name, domain_name):
|
||||
"""mimic dimail response upon successful allows creation.
|
||||
Dimail expects a name but our names are ProConnect's uuids."""
|
||||
@@ -303,8 +295,6 @@ def response_allows_created(user_name, domain_name):
|
||||
|
||||
|
||||
## MAILBOXES
|
||||
|
||||
|
||||
def response_mailbox_created(email_address):
|
||||
"""mimic dimail response upon successful mailbox creation."""
|
||||
return json.dumps({"email": email_address, "password": "password"})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Unit tests for the Alias model
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from mailbox_manager import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_aliases__devnull_destination_ok():
|
||||
"""Can create alias where destination is devnull@devnull."""
|
||||
|
||||
models.Alias.objects.create(
|
||||
local_part="spam",
|
||||
domain=factories.MailDomainEnabledFactory(),
|
||||
destination="devnull@devnull",
|
||||
)
|
||||
@@ -2,7 +2,6 @@
|
||||
Unit tests for admin actions
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.urls import reverse
|
||||
@@ -19,7 +18,6 @@ from .fixtures.dimail import (
|
||||
CHECK_DOMAIN_BROKEN,
|
||||
CHECK_DOMAIN_OK,
|
||||
DOMAIN_SPEC,
|
||||
TOKEN_OK,
|
||||
response_mailbox_created,
|
||||
)
|
||||
|
||||
@@ -76,17 +74,15 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
|
||||
body_content_domain1["name"] = domain1.name
|
||||
body_content_domain2 = CHECK_DOMAIN_BROKEN.copy()
|
||||
body_content_domain2["name"] = domain2.name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain1.name}/check/"),
|
||||
body=json.dumps(body_content_domain1),
|
||||
json=body_content_domain1,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain2.name}/check/"),
|
||||
body=json.dumps(body_content_domain2),
|
||||
json=body_content_domain2,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -101,7 +97,9 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
|
||||
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(
|
||||
client, dimail_token_ok
|
||||
):
|
||||
"""Test admin action should switch domain state to ENABLED
|
||||
when dimail's response is "ok". It should also activate any pending mailbox."""
|
||||
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
|
||||
@@ -119,22 +117,14 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
|
||||
body_content_domain1 = CHECK_DOMAIN_OK.copy()
|
||||
body_content_domain1["name"] = domain1.name
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain1.name}/check/"),
|
||||
body=json.dumps(body_content_domain1),
|
||||
json=body_content_domain1,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
# we need to get a token to create mailboxes
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body=TOKEN_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
# token call in fixtures
|
||||
responses.post(
|
||||
responses.POST,
|
||||
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
|
||||
body=response_mailbox_created(f"truc@{domain1.name}"),
|
||||
@@ -172,10 +162,9 @@ def test_fetch_domain_expected_config(client, domain_status):
|
||||
"action": "fetch_domain_expected_config_from_dimail",
|
||||
"_selected_action": [domain.id],
|
||||
}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/spec/"),
|
||||
body=json.dumps(DOMAIN_SPEC),
|
||||
json=DOMAIN_SPEC,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -208,7 +197,7 @@ def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(clie
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_send_pending_mailboxes(client):
|
||||
def test_send_pending_mailboxes(client, dimail_token_ok):
|
||||
"""Test admin action to send pending mailboxes to dimail."""
|
||||
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
|
||||
client.force_login(admin)
|
||||
@@ -223,15 +212,8 @@ def test_send_pending_mailboxes(client):
|
||||
|
||||
url = reverse("admin:mailbox_manager_maildomain_changelist")
|
||||
for mailbox in mailboxes:
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body=TOKEN_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
# token call in fixture
|
||||
responses.post(
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=response_mailbox_created(f"{mailbox.local_part}@{domain.name}"),
|
||||
status=status.HTTP_201_CREATED,
|
||||
@@ -247,7 +229,7 @@ def test_send_pending_mailboxes(client):
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_send_pending_mailboxes__listing_failed_mailboxes(client):
|
||||
def test_send_pending_mailboxes__listing_failed_mailboxes(client, dimail_token_ok):
|
||||
"""Test admin action to send pending mailboxes to dimail."""
|
||||
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
|
||||
client.force_login(admin)
|
||||
@@ -261,15 +243,8 @@ def test_send_pending_mailboxes__listing_failed_mailboxes(client):
|
||||
}
|
||||
|
||||
url = reverse("admin:mailbox_manager_maildomain_changelist")
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body=TOKEN_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
# token call in fixtures
|
||||
responses.post(
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=response_mailbox_created(f"{mailbox.local_part}@{domain.name}"),
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Unit tests for mailbox manager tasks.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
@@ -63,18 +62,16 @@ def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals
|
||||
(domain_failed, body_content_ok3),
|
||||
]:
|
||||
# Mock dimail API with success response
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_content),
|
||||
json=body_content,
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
)
|
||||
# domain_enabled2 is broken with internal error, we try to fix it
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain_enabled2.name}/fix/"),
|
||||
body=json.dumps(body_content_broken_internal),
|
||||
json=body_content_broken_internal,
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -170,10 +167,9 @@ def test_fetch_domains_status_error_handling(caplog):
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
|
||||
# Mock dimail API with error response
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps({"error": "Internal Server Error"}),
|
||||
json={"error": "Internal Server Error"},
|
||||
status=500,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
Unit tests for dimail client
|
||||
"""
|
||||
|
||||
import json
|
||||
# pylint: disable=W0613
|
||||
|
||||
import logging
|
||||
import re
|
||||
from email.errors import HeaderParseError, NonASCIILocalPartDefect
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
@@ -21,14 +19,14 @@ from .fixtures.dimail import (
|
||||
CHECK_DOMAIN_BROKEN_EXTERNAL,
|
||||
CHECK_DOMAIN_BROKEN_INTERNAL,
|
||||
CHECK_DOMAIN_OK,
|
||||
TOKEN_OK,
|
||||
response_mailbox_created,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_dimail_synchronization__already_sync():
|
||||
@responses.activate
|
||||
def test_dimail_synchronization__already_sync(dimail_token_ok):
|
||||
"""
|
||||
No mailbox should be created when everything is already synced.
|
||||
"""
|
||||
@@ -39,35 +37,26 @@ def test_dimail_synchronization__already_sync():
|
||||
assert pre_sync_mailboxes.count() == 3
|
||||
|
||||
dimail_client = DimailAPIClient()
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "dimail_people_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
[
|
||||
{
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"{mailbox.local_part}@{domain.name}",
|
||||
"givenName": mailbox.first_name,
|
||||
"surName": mailbox.last_name,
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
}
|
||||
for mailbox in pre_sync_mailboxes
|
||||
]
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
imported_mailboxes = dimail_client.import_mailboxes(domain)
|
||||
|
||||
# Ensure successful response using "responses":
|
||||
# token response in fixtures
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
json=[
|
||||
{
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"{mailbox.local_part}@{domain.name}",
|
||||
"givenName": mailbox.first_name,
|
||||
"surName": mailbox.last_name,
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
}
|
||||
for mailbox in pre_sync_mailboxes
|
||||
],
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
imported_mailboxes = dimail_client.import_mailboxes(domain)
|
||||
|
||||
post_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
|
||||
assert post_sync_mailboxes.count() == 3
|
||||
@@ -75,97 +64,159 @@ def test_dimail_synchronization__already_sync():
|
||||
assert set(models.Mailbox.objects.filter(domain=domain)) == set(pre_sync_mailboxes)
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "warning")
|
||||
def test_dimail_synchronization__synchronize_mailboxes(mock_warning):
|
||||
"""A mailbox existing solely on dimail should be synchronized
|
||||
upon calling sync function on its domain"""
|
||||
@responses.activate
|
||||
def test_dimail_synchronization__synchronize_mailboxes(caplog, dimail_token_ok): # pylint: disable=W0613, R0914
|
||||
"""Importing mailboxes from dimail should synchronize valid mailboxes
|
||||
and log errors for invalid ones."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
existing_alias = factories.AliasFactory(domain=domain)
|
||||
|
||||
dimail_client = DimailAPIClient()
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "dimail_people_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
# Ensure successful response using "responses":
|
||||
# successful token in fixtures
|
||||
mailbox_valid = {
|
||||
"type": "mailbox",
|
||||
"status": "ok",
|
||||
"email": f"validmailbox@{domain.name}",
|
||||
"givenName": "Michael",
|
||||
"surName": "Roch",
|
||||
"displayName": "Michael Roch",
|
||||
}
|
||||
mailbox_oxadmin = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"oxadmin@{domain.name}",
|
||||
"givenName": "Admin",
|
||||
"surName": "Context",
|
||||
"displayName": "Context Admin",
|
||||
}
|
||||
mailbox_with_wrong_domain = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": "johndoe@wrongdomain.com",
|
||||
"givenName": "John",
|
||||
"surName": "Doe",
|
||||
"displayName": "John Doe",
|
||||
}
|
||||
mailbox_with_invalid_domain = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"naw@ake@{domain.name}",
|
||||
"givenName": "Joe",
|
||||
"surName": "Doe",
|
||||
"displayName": "Joe Doe",
|
||||
}
|
||||
mailbox_with_invalid_local_part = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"obalmaské@{domain.name}",
|
||||
"givenName": "Jean",
|
||||
"surName": "Vang",
|
||||
"displayName": "Jean Vang",
|
||||
}
|
||||
mailbox_existing_alias = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"{existing_alias.local_part}@{domain.name}",
|
||||
"givenName": "Support",
|
||||
"surName": "email",
|
||||
"displayName": "Support email",
|
||||
}
|
||||
|
||||
mailbox_valid = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"oxadmin@{domain.name}",
|
||||
"givenName": "Admin",
|
||||
"surName": "Context",
|
||||
"displayName": "Context Admin",
|
||||
}
|
||||
mailbox_with_wrong_domain = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": "johndoe@wrongdomain.com",
|
||||
"givenName": "John",
|
||||
"surName": "Doe",
|
||||
"displayName": "John Doe",
|
||||
}
|
||||
mailbox_with_invalid_domain = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"naw@ake@{domain.name}",
|
||||
"givenName": "Joe",
|
||||
"surName": "Doe",
|
||||
"displayName": "Joe Doe",
|
||||
}
|
||||
mailbox_with_invalid_local_part = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"obalmaské@{domain.name}",
|
||||
"givenName": "Jean",
|
||||
"surName": "Vang",
|
||||
"displayName": "Jean Vang",
|
||||
}
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
json=[
|
||||
mailbox_valid,
|
||||
mailbox_oxadmin,
|
||||
mailbox_with_wrong_domain,
|
||||
mailbox_with_invalid_domain,
|
||||
mailbox_with_invalid_local_part,
|
||||
mailbox_existing_alias,
|
||||
],
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
[
|
||||
mailbox_valid,
|
||||
mailbox_with_wrong_domain,
|
||||
mailbox_with_invalid_domain,
|
||||
mailbox_with_invalid_local_part,
|
||||
]
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
imported_mailboxes = dimail_client.import_mailboxes(domain)
|
||||
|
||||
imported_mailboxes = dimail_client.import_mailboxes(domain)
|
||||
# 4 imports failed: oxadmin, wrong domain, HeaderParseError, NonASCIILocalPartDefect
|
||||
assert len(caplog.records) == 5
|
||||
log_messages = [record.message for record in caplog.records]
|
||||
|
||||
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
|
||||
assert mock_warning.call_count == 3
|
||||
expected_messages = [
|
||||
f"Not importing OX technical address: oxadmin@{domain.name}",
|
||||
f"Import of email {mailbox_with_wrong_domain['email']} failed because of a wrong domain",
|
||||
f"Import of email {mailbox_with_invalid_domain['email']} failed with error Invalid Domain",
|
||||
f"Import of email {mailbox_with_invalid_local_part['email']} failed with error local-part \
|
||||
contains non-ASCII characters)",
|
||||
]
|
||||
for message in expected_messages:
|
||||
assert message in log_messages
|
||||
|
||||
# first we try to import email with a wrong domain
|
||||
assert mock_warning.call_args_list[0][0] == (
|
||||
"Import of email %s failed because of a wrong domain",
|
||||
mailbox_with_wrong_domain["email"],
|
||||
)
|
||||
assert models.Mailbox.objects.count() == 2
|
||||
assert imported_mailboxes == [
|
||||
mailbox_valid["email"],
|
||||
mailbox_existing_alias["email"],
|
||||
]
|
||||
|
||||
# then we try to import email with invalid domain
|
||||
invalid_mailbox_log = mock_warning.call_args_list[1][0]
|
||||
assert invalid_mailbox_log[1] == mailbox_with_invalid_domain["email"]
|
||||
assert isinstance(invalid_mailbox_log[2], HeaderParseError)
|
||||
|
||||
# finally we try to import email with non ascii local part
|
||||
non_ascii_mailbox_log = mock_warning.call_args_list[2][0]
|
||||
assert non_ascii_mailbox_log[1] == mailbox_with_invalid_local_part["email"]
|
||||
assert isinstance(non_ascii_mailbox_log[2], NonASCIILocalPartDefect)
|
||||
@responses.activate
|
||||
def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint: disable=unused-argument
|
||||
"""Importing aliases from dimail should synchronize valid aliases
|
||||
and log errors for invalid ones."""
|
||||
alias = factories.AliasFactory()
|
||||
dimail_client = DimailAPIClient()
|
||||
|
||||
mailbox = models.Mailbox.objects.get()
|
||||
assert mailbox.local_part == "oxadmin"
|
||||
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
|
||||
assert imported_mailboxes == [mailbox_valid["email"]]
|
||||
existing_mailbox = factories.MailboxFactory(domain=alias.domain)
|
||||
|
||||
# Ensure successful response using "responses":
|
||||
# token response in fixtures
|
||||
incoming_aliases = [
|
||||
{
|
||||
"username": "contact",
|
||||
"domain": alias.domain.name,
|
||||
"destination": alias.destination, # same destination = ok
|
||||
"allow_to_send": False,
|
||||
},
|
||||
{
|
||||
"username": alias.local_part, # same username = ok
|
||||
"domain": alias.domain.name,
|
||||
"destination": "maheius.endorecles@somethingelse.com",
|
||||
"allow_to_send": False,
|
||||
},
|
||||
{ # same username + same destination = big nono
|
||||
"username": alias.local_part,
|
||||
"domain": alias.domain.name,
|
||||
"destination": alias.destination,
|
||||
"allow_to_send": False,
|
||||
},
|
||||
{ # mailbox with same username = ok
|
||||
"username": existing_mailbox.local_part,
|
||||
"domain": alias.domain.name,
|
||||
"destination": existing_mailbox.secondary_email,
|
||||
"allow_to_send": False,
|
||||
},
|
||||
{ # alias to devnull@devnull
|
||||
"username": "spam",
|
||||
"domain": alias.domain.name,
|
||||
"destination": "devnull@devnull",
|
||||
"allow_to_send": False,
|
||||
},
|
||||
]
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{alias.domain.name}/aliases/"),
|
||||
json=incoming_aliases,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
imported_aliases = dimail_client.import_aliases(alias.domain)
|
||||
|
||||
assert len(imported_aliases) == 4
|
||||
assert models.Alias.objects.count() == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -183,10 +234,9 @@ def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
|
||||
domain = factories.MailDomainFactory(status=domain_status)
|
||||
body_content = CHECK_DOMAIN_OK.copy()
|
||||
body_content["name"] = domain.name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_content),
|
||||
json=body_content,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -221,10 +271,9 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
|
||||
domain = factories.MailDomainFactory(status=domain_status)
|
||||
body_domain_broken = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
|
||||
body_domain_broken["name"] = domain.name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_broken),
|
||||
json=body_domain_broken,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -238,10 +287,9 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
|
||||
# Now domain is OK again
|
||||
body_domain_ok = CHECK_DOMAIN_OK.copy()
|
||||
body_domain_ok["name"] = domain.name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_ok),
|
||||
json=body_domain_ok,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -267,18 +315,16 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
|
||||
# nothing can be done by support team, domain should be in failed
|
||||
body_domain_broken = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
|
||||
body_domain_broken["name"] = domain.name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_broken),
|
||||
json=body_domain_broken,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
# the endpoint fix is called and still returns KO for internal checks
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/fix/"),
|
||||
body=json.dumps(body_domain_broken),
|
||||
json=body_domain_broken,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -305,10 +351,9 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
|
||||
# with all checks KO, domain should be in action required
|
||||
body_domain_broken = CHECK_DOMAIN_BROKEN.copy()
|
||||
body_domain_broken["name"] = domain.name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_broken),
|
||||
json=body_domain_broken,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -324,20 +369,18 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
|
||||
# the fetch_domain_status call
|
||||
body_domain_broken_internal = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
|
||||
body_domain_broken_internal["name"] = domain.name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_broken_internal),
|
||||
json=body_domain_broken_internal,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
# the endpoint fix is called and returns OK. Hooray!
|
||||
body_domain_ok = CHECK_DOMAIN_OK.copy()
|
||||
body_domain_ok["name"] = domain.name
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/fix/"),
|
||||
body=json.dumps(body_domain_ok),
|
||||
json=body_domain_ok,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -348,7 +391,8 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
|
||||
assert domain.last_check_details == body_domain_ok
|
||||
|
||||
|
||||
def test_dimail__send_pending_mailboxes(caplog):
|
||||
@responses.activate
|
||||
def test_dimail__send_pending_mailboxes(caplog, dimail_token_ok):
|
||||
"""Status of pending mailboxes should switch to "enabled"
|
||||
when calling send_pending_mailboxes."""
|
||||
caplog.set_level(logging.INFO)
|
||||
@@ -365,22 +409,16 @@ def test_dimail__send_pending_mailboxes(caplog):
|
||||
)
|
||||
|
||||
dimail_client = DimailAPIClient()
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body=TOKEN_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=response_mailbox_created(f"mock@{domain.name}"),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
dimail_client.send_pending_mailboxes(domain=domain)
|
||||
|
||||
# Ensure successful response using "responses":
|
||||
# token response in fixtures
|
||||
responses.post(
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=response_mailbox_created(f"mock@{domain.name}"),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
dimail_client.send_pending_mailboxes(domain=domain)
|
||||
|
||||
mailbox1.refresh_from_db()
|
||||
mailbox2.refresh_from_db()
|
||||
|
||||
@@ -29,7 +29,11 @@ maildomain_related_router.register(
|
||||
viewsets.MailDomainInvitationViewset,
|
||||
basename="invitations",
|
||||
)
|
||||
|
||||
maildomain_related_router.register(
|
||||
"aliases",
|
||||
viewsets.AliasViewSet,
|
||||
basename="aliases",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
"""A minimalist client to synchronize with mailbox provisioning API."""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import smtplib
|
||||
from email.errors import HeaderParseError, NonASCIILocalPartDefect
|
||||
@@ -46,7 +45,7 @@ class DimailAPIClient:
|
||||
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
|
||||
API_TIMEOUT = settings.MAIL_PROVISIONING_API_TIMEOUT
|
||||
|
||||
def get_headers(self):
|
||||
def _get_headers(self):
|
||||
"""
|
||||
Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
|
||||
to get a token from dimail /token/ endpoint.
|
||||
@@ -83,7 +82,7 @@ class DimailAPIClient:
|
||||
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
)
|
||||
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def create_domain(self, domain_name, request_user):
|
||||
"""Send a domain creation request to dimail API."""
|
||||
@@ -118,7 +117,7 @@ class DimailAPIClient:
|
||||
)
|
||||
return response
|
||||
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def create_mailbox(self, mailbox, request_user=None):
|
||||
"""Send a CREATE mailbox request to mail provisioning API."""
|
||||
@@ -131,7 +130,7 @@ class DimailAPIClient:
|
||||
# displayName value has to be unique
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
}
|
||||
headers = self.get_headers()
|
||||
headers = self._get_headers()
|
||||
|
||||
try:
|
||||
response = session.post(
|
||||
@@ -166,7 +165,36 @@ class DimailAPIClient:
|
||||
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
)
|
||||
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
# Dimail doesn't return a clear error for duplicate yet
|
||||
# but a 500 internal error
|
||||
if response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
try:
|
||||
address = session.get(
|
||||
f"{self.API_URL}/domains/{mailbox.domain.name}/address/{mailbox.local_part}/",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
logger.error(
|
||||
"Connection error while trying to reach %s.",
|
||||
self.API_URL,
|
||||
exc_info=error,
|
||||
)
|
||||
raise error
|
||||
|
||||
if address.status_code == status.HTTP_200_OK:
|
||||
primary = address.json()["ox_primary_email"]
|
||||
raise exceptions.ValidationError(
|
||||
{
|
||||
"NON_FIELD_ERRORS": [
|
||||
f"First name + last name combination already in use in this context : {primary}."
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def create_user(self, user_id):
|
||||
"""Send a request to dimail, to create a new user there. In dimail, user ids are subs."""
|
||||
@@ -203,7 +231,7 @@ class DimailAPIClient:
|
||||
)
|
||||
return response
|
||||
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def create_allow(self, user_id, domain_name):
|
||||
"""Send a request to dimail for a new 'allow' between user and the domain."""
|
||||
@@ -245,9 +273,9 @@ class DimailAPIClient:
|
||||
)
|
||||
return response
|
||||
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def raise_exception_for_unexpected_response(self, response):
|
||||
def _raise_exception_for_unexpected_response(self, response):
|
||||
"""Raise error when encountering an unexpected error in dimail."""
|
||||
try:
|
||||
error_content = json.loads(
|
||||
@@ -271,7 +299,7 @@ class DimailAPIClient:
|
||||
title, template_name, recipient, mailbox_data, issuer
|
||||
)
|
||||
|
||||
def notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
|
||||
def _notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
|
||||
"""
|
||||
Send email to notify of password reset
|
||||
and send new password.
|
||||
@@ -331,7 +359,7 @@ class DimailAPIClient:
|
||||
try:
|
||||
response = session.get(
|
||||
f"{self.API_URL}/domains/{domain.name}/mailboxes/",
|
||||
headers=self.get_headers(),
|
||||
headers=self._get_headers(),
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
@@ -344,43 +372,48 @@ class DimailAPIClient:
|
||||
raise error
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
|
||||
dimail_mailboxes = ast.literal_eval(
|
||||
response.content.decode("utf-8")
|
||||
) # format output str to proper list
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
dimail_mailboxes = response.json()
|
||||
people_mailboxes = models.Mailbox.objects.filter(domain=domain)
|
||||
imported_mailboxes = []
|
||||
for dimail_mailbox in dimail_mailboxes:
|
||||
if not dimail_mailbox["email"] in [
|
||||
try:
|
||||
address = Address(addr_spec=dimail_mailbox["email"])
|
||||
except (HeaderParseError, NonASCIILocalPartDefect) as error:
|
||||
logger.warning(
|
||||
"Import of email %s failed with error %s",
|
||||
dimail_mailbox["email"],
|
||||
error,
|
||||
)
|
||||
continue
|
||||
|
||||
if address.username == "oxadmin":
|
||||
logger.warning(
|
||||
"Not importing OX technical address: %s", dimail_mailbox["email"]
|
||||
)
|
||||
continue
|
||||
|
||||
if str(address) not in [
|
||||
str(people_mailbox) for people_mailbox in people_mailboxes
|
||||
]:
|
||||
try:
|
||||
# sometimes dimail api returns email from another domain,
|
||||
# so we decide to exclude this kind of email
|
||||
address = Address(addr_spec=dimail_mailbox["email"])
|
||||
if address.domain == domain.name:
|
||||
# creates a mailbox on our end
|
||||
mailbox = models.Mailbox.objects.create(
|
||||
first_name=dimail_mailbox["givenName"],
|
||||
last_name=dimail_mailbox["surName"],
|
||||
local_part=address.username,
|
||||
domain=domain,
|
||||
status=enums.MailboxStatusChoices.ENABLED,
|
||||
password=make_password(None), # unusable password
|
||||
)
|
||||
imported_mailboxes.append(str(mailbox))
|
||||
else:
|
||||
logger.warning(
|
||||
"Import of email %s failed because of a wrong domain",
|
||||
dimail_mailbox["email"],
|
||||
)
|
||||
except (HeaderParseError, NonASCIILocalPartDefect) as err:
|
||||
# sometimes dimail api returns email from another domain,
|
||||
# so we decide to exclude this kind of email
|
||||
if address.domain == domain.name:
|
||||
# creates a mailbox on our end
|
||||
mailbox = models.Mailbox.objects.create(
|
||||
first_name=dimail_mailbox["givenName"],
|
||||
last_name=dimail_mailbox["surName"],
|
||||
local_part=address.username,
|
||||
domain=domain,
|
||||
status=enums.MailboxStatusChoices.ENABLED,
|
||||
password=make_password(None), # unusable password
|
||||
)
|
||||
imported_mailboxes.append(str(mailbox))
|
||||
else:
|
||||
logger.warning(
|
||||
"Import of email %s failed with error %s",
|
||||
"Import of email %s failed because of a wrong domain",
|
||||
dimail_mailbox["email"],
|
||||
err,
|
||||
)
|
||||
return imported_mailboxes
|
||||
|
||||
@@ -389,7 +422,7 @@ class DimailAPIClient:
|
||||
response = session.patch(
|
||||
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}",
|
||||
json={"active": "no"},
|
||||
headers=self.get_headers(),
|
||||
headers=self._get_headers(),
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
@@ -401,7 +434,7 @@ class DimailAPIClient:
|
||||
request_user,
|
||||
)
|
||||
return response
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def enable_mailbox(self, mailbox, request_user=None):
|
||||
"""Send a request to enable a mailbox to dimail API"""
|
||||
@@ -413,7 +446,7 @@ class DimailAPIClient:
|
||||
"surName": mailbox.last_name,
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
},
|
||||
headers=self.get_headers(),
|
||||
headers=self._get_headers(),
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
@@ -425,7 +458,7 @@ class DimailAPIClient:
|
||||
request_user,
|
||||
)
|
||||
return response
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def send_pending_mailboxes(self, domain):
|
||||
"""Send requests for all pending mailboxes of a domain. Returns a list of failed mailboxes for this domain."""
|
||||
@@ -474,7 +507,7 @@ class DimailAPIClient:
|
||||
raise error
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
return response.json()
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def fix_domain(self, domain):
|
||||
"""Send a request to dimail to fix a domain.
|
||||
@@ -491,7 +524,7 @@ class DimailAPIClient:
|
||||
str(domain),
|
||||
)
|
||||
return response.json()
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def fetch_domain_status(self, domain):
|
||||
"""Send a request to check and update status of a domain."""
|
||||
@@ -616,7 +649,7 @@ class DimailAPIClient:
|
||||
try:
|
||||
response = session.post(
|
||||
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
|
||||
headers=self.get_headers(),
|
||||
headers=self._get_headers(),
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
@@ -630,7 +663,7 @@ class DimailAPIClient:
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
# send new password to secondary email
|
||||
self.notify_mailbox_password_reset(
|
||||
self._notify_mailbox_password_reset(
|
||||
recipient=mailbox.secondary_email,
|
||||
mailbox_data={
|
||||
"email": response.json()["email"],
|
||||
@@ -642,4 +675,183 @@ class DimailAPIClient:
|
||||
mailbox,
|
||||
)
|
||||
return response
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def create_alias(self, alias, request_user=None):
|
||||
"""Send a Create alias request to mail provisioning API."""
|
||||
|
||||
payload = {
|
||||
"user_name": alias.local_part,
|
||||
"destination": alias.destination,
|
||||
}
|
||||
headers = self._get_headers()
|
||||
|
||||
try:
|
||||
response = session.post(
|
||||
f"{self.API_URL}/domains/{alias.domain.name}/aliases/",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
logger.error(
|
||||
"Connection error while trying to reach %s.",
|
||||
self.API_URL,
|
||||
exc_info=error,
|
||||
)
|
||||
raise error
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
logger.info(
|
||||
"User %s linked alias %s to a new email.",
|
||||
request_user,
|
||||
f"{alias.local_part}@{alias.domain}",
|
||||
)
|
||||
return response
|
||||
|
||||
if response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
logger.error(
|
||||
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
|
||||
str(alias.domain),
|
||||
)
|
||||
raise exceptions.PermissionDenied(
|
||||
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_409_CONFLICT:
|
||||
logger.error(
|
||||
"[DIMAIL] Out of sync with dimail. Admin, please import aliases for domain %s",
|
||||
str(alias.domain),
|
||||
)
|
||||
raise exceptions.ValidationError(
|
||||
{
|
||||
"NON_FIELD_ERRORS": [
|
||||
"Alias already exists. Your domain is out of sync, please contact our support."
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def delete_alias(self, alias, request_user=None):
|
||||
"""Send a Delete alias request to mail provisioning API."""
|
||||
|
||||
headers = self._get_headers()
|
||||
|
||||
try:
|
||||
response = session.delete(
|
||||
f"{self.API_URL}/domains/{alias.domain.name}/aliases/{alias.local_part}/{alias.destination}",
|
||||
json={},
|
||||
headers=headers,
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
logger.error(
|
||||
"Connection error while trying to reach %s.",
|
||||
self.API_URL,
|
||||
exc_info=error,
|
||||
)
|
||||
raise error
|
||||
|
||||
if response.status_code == status.HTTP_204_NO_CONTENT:
|
||||
logger.info(
|
||||
"User %s removed destination %s from alias %s.",
|
||||
request_user,
|
||||
alias.destination,
|
||||
f"{alias.local_part}@{alias.domain}",
|
||||
)
|
||||
return response
|
||||
|
||||
if response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
logger.error(
|
||||
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
|
||||
str(alias.domain),
|
||||
)
|
||||
raise exceptions.PermissionDenied(
|
||||
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
logger.error(
|
||||
"[DIMAIL] 404, alias %s not found. Domain out of sync with dimail. Admin, please import aliases for domain %s",
|
||||
str(alias.local_part),
|
||||
str(alias.domain),
|
||||
)
|
||||
# we don't raise error because we actually want this alias to be deleted
|
||||
return response
|
||||
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
def delete_multiple_alias(self, local_part, domain_name):
|
||||
"""Send a Delete alias request to mail provisioning API."""
|
||||
|
||||
try:
|
||||
response = session.delete(
|
||||
f"{self.API_URL}/domains/{domain_name}/aliases/{local_part}/all",
|
||||
json={},
|
||||
headers=self._get_headers(),
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
logger.error(
|
||||
"Connection error while trying to reach %s.",
|
||||
self.API_URL,
|
||||
exc_info=error,
|
||||
)
|
||||
raise error
|
||||
# response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
||||
def import_aliases(self, domain):
|
||||
"""Import aliases from dimail. Useful if people fall out of sync with dimail."""
|
||||
|
||||
try:
|
||||
response = session.get(
|
||||
f"{self.API_URL}/domains/{domain.name}/aliases/",
|
||||
headers=self._get_headers(),
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
logger.error(
|
||||
"Connection error while trying to reach %s.",
|
||||
self.API_URL,
|
||||
exc_info=error,
|
||||
)
|
||||
raise error
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
incoming_aliases = response.json()
|
||||
known_aliases = [
|
||||
(known_alias.local_part, known_alias.destination)
|
||||
for known_alias in models.Alias.objects.filter(domain=domain)
|
||||
]
|
||||
|
||||
imported_aliases = []
|
||||
for incoming_alias in incoming_aliases:
|
||||
if (
|
||||
incoming_alias["username"],
|
||||
incoming_alias["destination"],
|
||||
) not in known_aliases:
|
||||
try:
|
||||
new_alias = models.Alias.objects.create(
|
||||
local_part=incoming_alias["username"],
|
||||
destination=incoming_alias["destination"],
|
||||
domain=domain,
|
||||
)
|
||||
except (HeaderParseError, NonASCIILocalPartDefect) as err:
|
||||
logger.warning(
|
||||
"Import of alias %s to %s failed with error %s",
|
||||
incoming_alias["username"],
|
||||
incoming_alias["destination"],
|
||||
err,
|
||||
)
|
||||
else:
|
||||
imported_aliases.append(str(new_alias))
|
||||
return imported_aliases
|
||||
|
||||
@@ -74,7 +74,7 @@ class Base(Configuration):
|
||||
You may also want to override default configuration by setting the following environment
|
||||
variables:
|
||||
|
||||
* DJANGO_SENTRY_DSN
|
||||
* SENTRY_DSN
|
||||
* DB_NAME
|
||||
* DB_HOST
|
||||
* DB_PASSWORD
|
||||
@@ -341,7 +341,7 @@ class Base(Configuration):
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
|
||||
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
@@ -581,26 +581,6 @@ class Base(Configuration):
|
||||
environ_name="MAIL_CHECK_DOMAIN_INTERVAL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DNS_PROVISIONING_TARGET_ZONE = values.Value(
|
||||
default=None,
|
||||
environ_name="DNS_PROVISIONING_TARGET_ZONE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DNS_PROVISIONING_API_URL = values.Value(
|
||||
default="https://api.scaleway.com",
|
||||
environ_name="DNS_PROVISIONING_API_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DNS_PROVISIONING_RESOURCE_ID = values.Value(
|
||||
default=None,
|
||||
environ_name="DNS_PROVISIONING_RESOURCE_ID",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DNS_PROVISIONING_API_CREDENTIALS = values.Value(
|
||||
default=None,
|
||||
environ_name="DNS_PROVISIONING_API_CREDENTIALS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MATRIX_BASE_HOME_SERVER = values.Value(
|
||||
default="https://matrix.agent.dinum.tchap.gouv.fr",
|
||||
environ_name="MATRIX_BASE_HOME_SERVER",
|
||||
@@ -1066,10 +1046,6 @@ class Production(Base):
|
||||
},
|
||||
},
|
||||
}
|
||||
SENTRY_DSN = values.Value(
|
||||
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
|
||||
environ_name="SENTRY_DSN",
|
||||
)
|
||||
|
||||
|
||||
class Feature(Production):
|
||||
|
||||
@@ -11,22 +11,9 @@ from core.plugins.registry import register_hook
|
||||
from plugins.la_suite.hooks_utils.all_organizations import (
|
||||
get_organization_name_and_metadata_from_siret,
|
||||
)
|
||||
from plugins.la_suite.hooks_utils.communes import CommuneCreation
|
||||
|
||||
|
||||
@register_hook("organization_created")
|
||||
def get_organization_name_and_metadata_from_siret_hook(organization):
|
||||
"""After creating an organization, update the organization name & metadata."""
|
||||
get_organization_name_and_metadata_from_siret(organization)
|
||||
|
||||
|
||||
@register_hook("organization_created")
|
||||
def commune_organization_created(organization):
|
||||
"""After creating an organization, update the organization name."""
|
||||
CommuneCreation().run_after_create(organization)
|
||||
|
||||
|
||||
@register_hook("organization_access_granted")
|
||||
def commune_organization_access_granted(organization_access):
|
||||
"""After granting an organization access, check for needed domain access grant."""
|
||||
CommuneCreation().run_after_grant_access(organization_access)
|
||||
|
||||
@@ -21,25 +21,45 @@ API_URL = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
||||
|
||||
def _get_organization_name_and_metadata_from_results(data, siret):
|
||||
"""Return the organization name and metadata from the results of a SIRET search."""
|
||||
org_metadata = {}
|
||||
for result in data["results"]:
|
||||
for organization in result["matching_etablissements"]:
|
||||
if organization.get("siret") == siret:
|
||||
org_metadata["is_public_service"] = result.get("complements", {}).get(
|
||||
"est_service_public", False
|
||||
)
|
||||
org_metadata["is_commune"] = (
|
||||
str(result.get("nature_juridique", "")) == "7210"
|
||||
)
|
||||
# Find matching organization
|
||||
match = next(
|
||||
(
|
||||
(res, org)
|
||||
for res in data.get("results", [])
|
||||
for org in res.get("matching_etablissements", [])
|
||||
if org.get("siret") == siret
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
store_signs = organization.get("liste_enseignes") or []
|
||||
if store_signs:
|
||||
return store_signs[0].title(), org_metadata
|
||||
if name := result.get("nom_raison_sociale"):
|
||||
return name.title(), org_metadata
|
||||
if not match:
|
||||
logger.warning("No organization name found for SIRET %s", siret)
|
||||
return None, {}
|
||||
|
||||
result, organization = match
|
||||
|
||||
# Extract metadata
|
||||
is_commune = str(result.get("nature_juridique", "")) == "7210"
|
||||
metadata = {
|
||||
"is_public_service": result.get("complements", {}).get(
|
||||
"est_service_public", False
|
||||
),
|
||||
"is_commune": is_commune,
|
||||
}
|
||||
|
||||
# Extract name (priority: commune name > store signs > business name)
|
||||
name = None
|
||||
if is_commune:
|
||||
name = result.get("siege", {}).get("libelle_commune")
|
||||
if not name: # Fallback for non-communes OR if commune has no libelle_commune
|
||||
store_signs = organization.get("liste_enseignes") or []
|
||||
name = store_signs[0] if store_signs else result.get("nom_raison_sociale")
|
||||
|
||||
if name:
|
||||
return name.title(), metadata
|
||||
|
||||
logger.warning("No organization name found for SIRET %s", siret)
|
||||
return None, org_metadata
|
||||
return None, metadata
|
||||
|
||||
|
||||
def get_organization_name_and_metadata_from_siret(organization):
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"""Organization related plugins."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter, Retry
|
||||
|
||||
from mailbox_manager.enums import MailDomainRoleChoices
|
||||
from mailbox_manager.models import MailDomain, MailDomainAccess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApiCall:
|
||||
"""Encapsulates a call to an external API"""
|
||||
|
||||
inputs: dict = {}
|
||||
method: str = "GET"
|
||||
base: str = ""
|
||||
url: str = ""
|
||||
params: dict = {}
|
||||
headers: dict = {}
|
||||
response_data = None
|
||||
|
||||
def execute(self):
|
||||
"""Call the specified API endpoint with supplied parameters and record response"""
|
||||
if self.method in ("POST", "PATCH"):
|
||||
response = requests.request(
|
||||
method=self.method,
|
||||
url=f"{self.base}/{self.url}",
|
||||
json=self.params,
|
||||
headers=self.headers,
|
||||
timeout=20,
|
||||
)
|
||||
else:
|
||||
response = requests.request(
|
||||
method=self.method,
|
||||
url=f"{self.base}/{self.url}",
|
||||
params=self.params,
|
||||
headers=self.headers,
|
||||
timeout=20,
|
||||
)
|
||||
self.response_data = response.json()
|
||||
logger.info(
|
||||
"API call: %s %s %s %s",
|
||||
self.method,
|
||||
self.url,
|
||||
self.params,
|
||||
self.response_data,
|
||||
)
|
||||
|
||||
|
||||
class CommuneCreation:
|
||||
"""
|
||||
This plugin handles setup tasks for French communes.
|
||||
"""
|
||||
|
||||
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
||||
|
||||
def get_organization_name_from_results(self, data, siret):
|
||||
"""Return the organization name from the results of a SIRET search."""
|
||||
for result in data["results"]:
|
||||
nature = "nature_juridique"
|
||||
commune = nature in result and result[nature] == "7210"
|
||||
if commune:
|
||||
return result["siege"]["libelle_commune"].title()
|
||||
|
||||
logger.warning("Not a commune: SIRET %s", siret)
|
||||
return None
|
||||
|
||||
def dns_call(self, spec):
|
||||
"""Call to add a DNS record"""
|
||||
zone_name = self.zone_name(spec.inputs["name"])
|
||||
|
||||
records = [
|
||||
{
|
||||
"name": item["target"],
|
||||
"type": item["type"].upper(),
|
||||
"data": item["value"],
|
||||
"ttl": 3600,
|
||||
}
|
||||
for item in spec.response_data
|
||||
]
|
||||
result = ApiCall()
|
||||
result.method = "PATCH"
|
||||
result.base = "https://api.scaleway.com"
|
||||
result.url = f"/domain/v2beta1/dns-zones/{zone_name}/records"
|
||||
result.params = {"changes": [{"add": {"records": records}}]}
|
||||
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
|
||||
return result
|
||||
|
||||
def normalize_name(self, name: str) -> str:
|
||||
"""Map the name to a standard form"""
|
||||
name = re.sub("'", "-", name)
|
||||
return slugify(name)
|
||||
|
||||
def zone_name(self, name: str) -> str:
|
||||
"""Derive the zone name from the commune name"""
|
||||
normalized = self.normalize_name(name)
|
||||
return f"{normalized}.{settings.DNS_PROVISIONING_TARGET_ZONE}"
|
||||
|
||||
def complete_commune_creation(self, name: str) -> ApiCall:
|
||||
"""Specify the tasks to be completed after a commune is created."""
|
||||
inputs = {"name": self.normalize_name(name)}
|
||||
|
||||
create_zone = ApiCall()
|
||||
create_zone.method = "POST"
|
||||
create_zone.base = "https://api.scaleway.com"
|
||||
create_zone.url = "/domain/v2beta1/dns-zones"
|
||||
create_zone.params = {
|
||||
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
|
||||
"domain": settings.DNS_PROVISIONING_TARGET_ZONE,
|
||||
"subdomain": inputs["name"],
|
||||
}
|
||||
create_zone.headers = {
|
||||
"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS
|
||||
}
|
||||
|
||||
zone_name = self.zone_name(inputs["name"])
|
||||
|
||||
create_domain = ApiCall()
|
||||
create_domain.method = "POST"
|
||||
create_domain.base = settings.MAIL_PROVISIONING_API_URL
|
||||
create_domain.url = "/domains/"
|
||||
create_domain.params = {
|
||||
"name": zone_name,
|
||||
"delivery": "virtual",
|
||||
"features": ["webmail", "mailbox"],
|
||||
"context_name": zone_name,
|
||||
}
|
||||
create_domain.headers = {
|
||||
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
||||
}
|
||||
|
||||
spec_domain = ApiCall()
|
||||
spec_domain.inputs = inputs
|
||||
spec_domain.base = settings.MAIL_PROVISIONING_API_URL
|
||||
spec_domain.url = f"/domains/{zone_name}/spec"
|
||||
spec_domain.headers = {
|
||||
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
||||
}
|
||||
|
||||
return [create_zone, create_domain, spec_domain]
|
||||
|
||||
def complete_zone_creation(self, spec_call):
|
||||
"""Specify the tasks to be performed to set up the zone."""
|
||||
return self.dns_call(spec_call)
|
||||
|
||||
def run_after_create(self, organization):
|
||||
"""After creating an organization, update the organization name."""
|
||||
logger.info("In CommuneCreation")
|
||||
if not organization.registration_id_list:
|
||||
# No registration ID to convert...
|
||||
return
|
||||
|
||||
# In the nominal case, there is only one registration ID because
|
||||
# the organization has been created from it.
|
||||
try:
|
||||
# Retry logic as the API may be rate limited
|
||||
s = requests.Session()
|
||||
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
|
||||
s.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
|
||||
siret = organization.registration_id_list[0]
|
||||
response = s.get(self._api_url.format(siret=siret), timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
name = self.get_organization_name_from_results(data, siret)
|
||||
# Not a commune ?
|
||||
if not name:
|
||||
return
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
|
||||
return
|
||||
|
||||
organization.name = name
|
||||
organization.save(update_fields=["name", "updated_at"])
|
||||
logger.info("Organization %s name updated to %s", organization, name)
|
||||
|
||||
zone_name = self.zone_name(name)
|
||||
support = "support-regie@numerique.gouv.fr"
|
||||
MailDomain.objects.get_or_create(name=zone_name, support_email=support)
|
||||
|
||||
# Compute and execute the rest of the process
|
||||
tasks = self.complete_commune_creation(name)
|
||||
for task in tasks:
|
||||
task.execute()
|
||||
last_task = self.complete_zone_creation(tasks[-1])
|
||||
last_task.execute()
|
||||
|
||||
def complete_grant_access(self, sub, zone_name):
|
||||
"""Specify the tasks to be completed after making a user admin"""
|
||||
create_user = ApiCall()
|
||||
create_user.method = "POST"
|
||||
create_user.base = settings.MAIL_PROVISIONING_API_URL
|
||||
create_user.url = "/users/"
|
||||
create_user.params = {
|
||||
"name": sub,
|
||||
"password": "no",
|
||||
"is_admin": False,
|
||||
"perms": [],
|
||||
}
|
||||
create_user.headers = {
|
||||
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
||||
}
|
||||
|
||||
grant_access = ApiCall()
|
||||
grant_access.method = "POST"
|
||||
grant_access.base = settings.MAIL_PROVISIONING_API_URL
|
||||
grant_access.url = "/allows/"
|
||||
grant_access.params = {
|
||||
"user": sub,
|
||||
"domain": zone_name,
|
||||
}
|
||||
grant_access.headers = {
|
||||
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
||||
}
|
||||
|
||||
return [create_user, grant_access]
|
||||
|
||||
def run_after_grant_access(self, organization_access):
|
||||
"""After granting an organization access, check for needed domain access grant."""
|
||||
orga = organization_access.organization
|
||||
user = organization_access.user
|
||||
zone_name = self.zone_name(orga.name)
|
||||
|
||||
try:
|
||||
domain = MailDomain.objects.get(name=zone_name)
|
||||
except MailDomain.DoesNotExist:
|
||||
domain = None
|
||||
|
||||
if domain:
|
||||
MailDomainAccess.objects.create(
|
||||
domain=domain, user=user, role=MailDomainRoleChoices.OWNER
|
||||
)
|
||||
|
||||
tasks = self.complete_grant_access(user.sub, zone_name)
|
||||
for task in tasks:
|
||||
task.execute()
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Tests for the CommuneCreation plugin."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from plugins.la_suite.hooks_utils.communes import ApiCall, CommuneCreation
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_extract_name_from_org_data_when_commune():
|
||||
"""Test the name is extracted correctly for a French commune."""
|
||||
data = {
|
||||
"results": [
|
||||
{
|
||||
"nom_complet": "COMMUNE DE VARZY",
|
||||
"nom_raison_sociale": "COMMUNE DE VARZY",
|
||||
"siege": {
|
||||
"libelle_commune": "VARZY",
|
||||
"liste_enseignes": ["MAIRIE"],
|
||||
"siret": "21580304000017",
|
||||
},
|
||||
"nature_juridique": "7210",
|
||||
"matching_etablissements": [
|
||||
{
|
||||
"siret": "21580304000017",
|
||||
"libelle_commune": "VARZY",
|
||||
"liste_enseignes": ["MAIRIE"],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
plugin = CommuneCreation()
|
||||
name = plugin.get_organization_name_from_results(data, "21580304000017")
|
||||
assert name == "Varzy"
|
||||
|
||||
|
||||
def test_api_call_execution():
|
||||
"""Test that calling execute() faithfully executes the API call"""
|
||||
task = ApiCall()
|
||||
task.method = "POST"
|
||||
task.base = "https://some_host"
|
||||
task.url = "some_url"
|
||||
task.params = {"some_key": "some_value"}
|
||||
task.headers = {"Some-Header": "Some-Header-Value"}
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
url="https://some_host/some_url",
|
||||
body='{"some_key": "some_value"}',
|
||||
content_type="application/json",
|
||||
headers={"Some-Header": "Some-Header-Value"},
|
||||
)
|
||||
|
||||
task.execute()
|
||||
|
||||
|
||||
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
|
||||
def test_tasks_on_commune_creation_include_zone_creation():
|
||||
"""Test the first task in commune creation: creating the DNS sub-zone"""
|
||||
plugin = CommuneCreation()
|
||||
name = "Varzy"
|
||||
|
||||
tasks = plugin.complete_commune_creation(name)
|
||||
|
||||
assert tasks[0].base == "https://api.scaleway.com"
|
||||
assert tasks[0].url == "/domain/v2beta1/dns-zones"
|
||||
assert tasks[0].method == "POST"
|
||||
assert tasks[0].params == {
|
||||
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
|
||||
"domain": "collectivite.fr",
|
||||
"subdomain": "varzy",
|
||||
}
|
||||
assert tasks[0].headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
|
||||
|
||||
|
||||
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
|
||||
def test_tasks_on_commune_creation_include_dimail_domain_creation():
|
||||
"""Test the second task in commune creation: creating the domain in Dimail"""
|
||||
plugin = CommuneCreation()
|
||||
name = "Merlaut"
|
||||
|
||||
tasks = plugin.complete_commune_creation(name)
|
||||
|
||||
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
|
||||
assert tasks[1].url == "/domains/"
|
||||
assert tasks[1].method == "POST"
|
||||
assert tasks[1].params == {
|
||||
"name": "merlaut.collectivite.fr",
|
||||
"delivery": "virtual",
|
||||
"features": ["webmail", "mailbox"],
|
||||
"context_name": "merlaut.collectivite.fr",
|
||||
}
|
||||
assert (
|
||||
tasks[1].headers["Authorization"]
|
||||
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
|
||||
def test_tasks_on_commune_creation_include_fetching_spec():
|
||||
"""Test the third task in commune creation: asking Dimail for the spec"""
|
||||
plugin = CommuneCreation()
|
||||
name = "Loc-Eguiner"
|
||||
|
||||
tasks = plugin.complete_commune_creation(name)
|
||||
|
||||
assert tasks[2].base == settings.MAIL_PROVISIONING_API_URL
|
||||
assert tasks[2].url == "/domains/loc-eguiner.collectivite.fr/spec"
|
||||
assert tasks[2].method == "GET"
|
||||
assert (
|
||||
tasks[2].headers["Authorization"]
|
||||
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
|
||||
def test_tasks_on_commune_creation_include_dns_records():
|
||||
"""Test the next several tasks in commune creation: creating records"""
|
||||
plugin = CommuneCreation()
|
||||
name = "Abidos"
|
||||
|
||||
spec_response = [
|
||||
{"target": "", "type": "mx", "value": "mx.dev.ox.numerique.gouv.fr."},
|
||||
{
|
||||
"target": "dimail._domainkey",
|
||||
"type": "txt",
|
||||
"value": "v=DKIM1; h=sha256; k=rsa; p=MIICIjANB<truncated>AAQ==",
|
||||
},
|
||||
{"target": "imap", "type": "cname", "value": "imap.dev.ox.numerique.gouv.fr."},
|
||||
{"target": "smtp", "type": "cname", "value": "smtp.dev.ox.numerique.gouv.fr."},
|
||||
{
|
||||
"target": "",
|
||||
"type": "txt",
|
||||
"value": "v=spf1 include:_spf.dev.ox.numerique.gouv.fr -all",
|
||||
},
|
||||
{
|
||||
"target": "webmail",
|
||||
"type": "cname",
|
||||
"value": "webmail.dev.ox.numerique.gouv.fr.",
|
||||
},
|
||||
]
|
||||
|
||||
tasks = plugin.complete_commune_creation(name)
|
||||
tasks[2].response_data = spec_response
|
||||
|
||||
expected = {
|
||||
"changes": [
|
||||
{
|
||||
"add": {
|
||||
"records": [
|
||||
{
|
||||
"name": item["target"],
|
||||
"type": item["type"].upper(),
|
||||
"data": item["value"],
|
||||
"ttl": 3600,
|
||||
}
|
||||
for item in spec_response
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
zone_call = plugin.complete_zone_creation(tasks[2])
|
||||
assert zone_call.params == expected
|
||||
assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records"
|
||||
assert (
|
||||
zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
|
||||
)
|
||||
|
||||
|
||||
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
|
||||
def test_tasks_on_grant_access():
|
||||
"""Test the final tasks after making user admin of an org"""
|
||||
plugin = CommuneCreation()
|
||||
|
||||
tasks = plugin.complete_grant_access("some-sub", "mezos.collectivite.fr")
|
||||
|
||||
assert tasks[0].base == settings.MAIL_PROVISIONING_API_URL
|
||||
assert tasks[0].url == "/users/"
|
||||
assert tasks[0].method == "POST"
|
||||
assert tasks[0].params == {
|
||||
"name": "some-sub",
|
||||
"password": "no",
|
||||
"is_admin": False,
|
||||
"perms": [],
|
||||
}
|
||||
assert (
|
||||
tasks[0].headers["Authorization"]
|
||||
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
||||
)
|
||||
|
||||
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
|
||||
assert tasks[1].url == "/allows/"
|
||||
assert tasks[1].method == "POST"
|
||||
assert tasks[1].params == {
|
||||
"user": "some-sub",
|
||||
"domain": "mezos.collectivite.fr",
|
||||
}
|
||||
assert (
|
||||
tasks[1].headers["Authorization"]
|
||||
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_name():
|
||||
"""Test name normalization"""
|
||||
plugin = CommuneCreation()
|
||||
assert plugin.normalize_name("Asnières-sur-Saône") == "asnieres-sur-saone"
|
||||
assert plugin.normalize_name("Bâgé-le-Châtel") == "bage-le-chatel"
|
||||
assert plugin.normalize_name("Courçais") == "courcais"
|
||||
assert plugin.normalize_name("Moÿ-de-l'Aisne") == "moy-de-l-aisne"
|
||||
assert plugin.normalize_name("Salouël") == "salouel"
|
||||
assert (
|
||||
plugin.normalize_name("Bors (Canton de Tude-et-Lavalette)")
|
||||
== "bors-canton-de-tude-et-lavalette"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
|
||||
def test_zone_name():
|
||||
"""Test transforming a commune name to a sub-zone of collectivite.fr"""
|
||||
plugin = CommuneCreation()
|
||||
assert plugin.zone_name("Bâgé-le-Châtel") == "bage-le-chatel.collectivite.fr"
|
||||
+4
-8
@@ -61,8 +61,7 @@ def test_organization_plugins_run_after_create(
|
||||
hook_settings, nature_juridique, is_commune, is_public_service
|
||||
):
|
||||
"""Test the run_after_create method of the organization plugins for nominal case."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
|
||||
json={
|
||||
"results": [
|
||||
@@ -106,8 +105,7 @@ def test_organization_plugins_run_after_create(
|
||||
@responses.activate
|
||||
def test_organization_plugins_run_after_create_api_fail(hook_settings):
|
||||
"""Test the plugin when the API call fails."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
|
||||
json={"error": "Internal Server Error"},
|
||||
status=500,
|
||||
@@ -139,8 +137,7 @@ def test_organization_plugins_run_after_create_api_fail(hook_settings):
|
||||
)
|
||||
def test_organization_plugins_run_after_create_missing_data(hook_settings, results):
|
||||
"""Test the plugin when the API call returns missing data."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
|
||||
json=results,
|
||||
status=200,
|
||||
@@ -168,8 +165,7 @@ def test_organization_plugins_run_after_create_no_list_enseignes(
|
||||
hook_settings,
|
||||
):
|
||||
"""Test the run_after_create method of the organization plugins for nominal case."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
|
||||
json={
|
||||
"results": [
|
||||
|
||||
@@ -26,15 +26,6 @@ def test_hooks_loaded():
|
||||
]
|
||||
assert organization_created_hook_names == [
|
||||
"get_organization_name_and_metadata_from_siret_hook",
|
||||
"commune_organization_created",
|
||||
]
|
||||
|
||||
organization_access_granted_hook_names = [
|
||||
callback.__name__
|
||||
for callback in registry.get_callbacks("organization_access_granted")
|
||||
]
|
||||
assert organization_access_granted_hook_names == [
|
||||
"commune_organization_access_granted"
|
||||
]
|
||||
|
||||
# cleanup the hooks
|
||||
|
||||
+39
-38
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "people"
|
||||
version = "1.18.0"
|
||||
version = "1.22.2"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,42 +25,43 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Brotli==1.1.0",
|
||||
"Brotli==1.2.0",
|
||||
"PyJWT==2.10.1",
|
||||
"boto3==1.38.45",
|
||||
"celery[redis]==5.5.3",
|
||||
"boto3==1.42.24",
|
||||
"celery[redis]==5.6.2",
|
||||
"django-celery-beat==2.8.1",
|
||||
"django-celery-results==2.6.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-extensions==4.1",
|
||||
"django-lasuite==0.0.10",
|
||||
"django-oauth-toolkit==3.0.1",
|
||||
"django-lasuite==0.0.22",
|
||||
"django-oauth-toolkit==3.2.0",
|
||||
"django-parler==2.3",
|
||||
"django-redis==5.4.0",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-treebeard==4.7.1",
|
||||
"django-zxcvbn-password-validator==1.4.5",
|
||||
"django==5.2.3",
|
||||
"djangorestframework==3.16.0",
|
||||
"django-treebeard==4.8.0",
|
||||
"django-zxcvbn-password-validator==1.5.2",
|
||||
"django==5.2.9",
|
||||
"djangorestframework==3.16.1",
|
||||
"dockerflow==2024.4.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"drf_spectacular[sidecar]==0.28.0",
|
||||
"easy_thumbnails==2.10",
|
||||
"drf_spectacular==0.29.0",
|
||||
"drf_spectacular[sidecar]==0.29.0",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"flower==2.0.1",
|
||||
"gunicorn==23.0.0",
|
||||
"joserfc==1.1.0",
|
||||
"jsonschema==4.24.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"redis<=6.0.0",
|
||||
"requests==2.32.4",
|
||||
"sentry-sdk[django]==2.32.0",
|
||||
"whitenoise==6.9.0",
|
||||
"jaraco.context>=6.1.0",
|
||||
"joserfc==1.6.1",
|
||||
"jsonschema==4.26.0",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"redis<=7.1.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk[django]==2.49.0",
|
||||
"whitenoise==6.11.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -71,22 +72,22 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"drf-spectacular-sidecar==2025.6.1",
|
||||
"drf-spectacular-sidecar==2026.1.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.3.0",
|
||||
"jq==1.9.1",
|
||||
"pyfakefs==5.9.1",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.7",
|
||||
"pytest-cov==6.2.1",
|
||||
"ipython==9.9.0",
|
||||
"jq==1.10.0",
|
||||
"pyfakefs==6.0.0",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint==4.0.4",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==8.4.1",
|
||||
"pytest==9.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.7.0",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.11.13",
|
||||
"types-requests==2.32.4.20250611",
|
||||
"freezegun==1.5.2",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"ruff==0.14.11",
|
||||
"types-requests==2.32.4.20260107",
|
||||
"freezegun==1.5.5",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -47,6 +47,13 @@ RUN yarn build
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
USER root
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
rm -rf /var/cache/apk/*
|
||||
USER -
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
|
||||
@@ -41,6 +41,7 @@ const nextConfig = {
|
||||
|
||||
return config;
|
||||
},
|
||||
turbopack: {},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-desk",
|
||||
"version": "1.18.0",
|
||||
"version": "1.22.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -16,53 +16,53 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "^0.5.0",
|
||||
"@gouvfr-lasuite/ui-kit": "^0.17.0",
|
||||
"@hookform/resolvers": "4.0.0",
|
||||
"@openfun/cunningham-react": "3.1.0",
|
||||
"@tanstack/react-query": "5.76.1",
|
||||
"i18next": "25.1.3",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.6.1",
|
||||
"next": "15.3.2",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"i18next": "25.6.0",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"lodash": "4.17.23",
|
||||
"luxon": "3.7.2",
|
||||
"next": "15.4.10",
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-hook-form": "7.56.3",
|
||||
"react-i18next": "15.5.1",
|
||||
"react-select": "5.10.1",
|
||||
"sass": "1.89.0",
|
||||
"styled-components": "6.1.18",
|
||||
"zod": "3.24.4",
|
||||
"zustand": "5.0.4"
|
||||
"react-hook-form": "7.65.0",
|
||||
"react-i18next": "16.2.0",
|
||||
"react-select": "5.10.2",
|
||||
"sass": "1.93.2",
|
||||
"styled-components": "6.1.19",
|
||||
"zod": "4.1.12",
|
||||
"zustand": "5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/devtools": "4.4.0",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.76.1",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.16",
|
||||
"@types/luxon": "3.6.2",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "*",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "*",
|
||||
"dotenv": "16.5.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint-config-people": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jest": "30.2.0",
|
||||
"jest-environment-jsdom": "30.2.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "16.19.1",
|
||||
"stylelint-config-standard": "38.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"stylelint": "16.25.0",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*"
|
||||
},
|
||||
"resolutions": {
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,17 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
|
||||
>
|
||||
{label} {required && '*'}
|
||||
</label>
|
||||
{error && (
|
||||
<Text $size="xs" $theme="danger" $variation="600">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<input
|
||||
id={label}
|
||||
aria-required={required}
|
||||
required={required}
|
||||
style={{
|
||||
padding: '12px',
|
||||
margin: '6px 0',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
border: `1px solid ${error ? colorsTokens()['danger-500'] : colorsTokens()['greyscale-400']}`,
|
||||
@@ -34,11 +38,6 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<Text $size="xs" $color="error-500">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './Tag';
|
||||
export * from './Text';
|
||||
export * from './TextErrors';
|
||||
export * from './separators';
|
||||
export * from './tabs/CustomTabs';
|
||||
|
||||
@@ -27,7 +27,7 @@ export const CustomTabs = ({ tabs }: Props) => {
|
||||
const id = tab.id ?? tab.label;
|
||||
return (
|
||||
<Tab key={id} aria-label={tab.ariaLabel} id={id}>
|
||||
<Box $direction="row" $align="center" $gap="5px">
|
||||
<Box $direction="row" $gap="5px">
|
||||
{tab.iconName && (
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
{tab.iconName}
|
||||
|
||||
@@ -1,63 +1,69 @@
|
||||
.customTabsContainer {
|
||||
:global {
|
||||
.react-aria-TabList {
|
||||
display: flex;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 30px;
|
||||
|
||||
&[data-orientation='horizontal'] {
|
||||
.react-aria-Tab {
|
||||
border-bottom: 2px solid var(--c--theme--colors--greyscale-500);
|
||||
}
|
||||
}
|
||||
:global(.react-aria-Tabs) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
&[data-orientation='horizontal'] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.react-aria-TabList) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
:global(.react-aria-Tab) {
|
||||
display: flex;
|
||||
padding: 0 10px 10px 10px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
position: relative;
|
||||
color: var(--c--theme--colors--secondary-900);
|
||||
transition: color 200ms;
|
||||
--border-color: transparent;
|
||||
forced-color-adjust: none;
|
||||
|
||||
&[data-hovered] {
|
||||
background-color: #fff;
|
||||
color: var(--text-color-hover);
|
||||
}
|
||||
|
||||
.react-aria-Tab {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
position: relative;
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
transition: color 200ms;
|
||||
|
||||
--border-color: transparent;
|
||||
|
||||
forced-color-adjust: none;
|
||||
|
||||
&[data-hovered],
|
||||
&[data-focused] {
|
||||
color: var(--c--theme--colors--greyscale-900);
|
||||
}
|
||||
&[data-selected] {
|
||||
font-weight: 500;
|
||||
color: var(--c--theme--colors--primary-text);
|
||||
border-bottom: 2px solid var(--c--theme--colors--primary-text);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: var(--text-color-disabled);
|
||||
&[data-selected] {
|
||||
border-bottom: 2px solid var(--c--theme--colors--primary-600) !important;
|
||||
color: var(--c--theme--colors--primary-600);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
|
||||
&[data-selected] {
|
||||
--border-color: var(--c--theme--colors--greyscale-200);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-focus-visible]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--c--theme--colors--primary-600);
|
||||
--border-color: var(--text-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.react-aria-TabPanel {
|
||||
margin-top: 4px;
|
||||
padding: 10px;
|
||||
&[data-focus-visible]:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
border: 2px solid var(--focus-ring-color);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-focus-visible] {
|
||||
outline: 2px solid var(--c--theme--colors--primary-600);
|
||||
}
|
||||
:global(.react-aria-TabPanel) {
|
||||
margin-top: 15px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&[data-focus-visible] {
|
||||
outline: 2px solid var(--focus-ring-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ describe('useDeleteMailDomainAccess', () => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
{ slug: 'example-slug', accessId: '1-1-1-1-1' },
|
||||
undefined,
|
||||
undefined, // context
|
||||
),
|
||||
);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
|
||||
+26
-3
@@ -44,19 +44,42 @@ export const useCreateMailDomainAccess = (
|
||||
options?: UseCreateMailDomainAccessOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess: optionsOnSuccess,
|
||||
onError: optionsOnError,
|
||||
...restOptions
|
||||
} = options || {};
|
||||
|
||||
return useMutation<Access, APIError, CreateMailDomainAccessProps>({
|
||||
mutationFn: createMailDomainAccess,
|
||||
...options,
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: [KEY_MAIL_DOMAIN] });
|
||||
options?.onSuccess?.(data, variables, context);
|
||||
if (optionsOnSuccess) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnSuccess as unknown as (
|
||||
data: Access,
|
||||
variables: CreateMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
options?.onError?.(error, variables, context);
|
||||
if (optionsOnError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnError as unknown as (
|
||||
error: APIError,
|
||||
variables: CreateMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
+24
-5
@@ -46,9 +46,14 @@ export const useDeleteMailDomainAccess = (
|
||||
options?: UseDeleteMailDomainAccessOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess: optionsOnSuccess,
|
||||
onError: optionsOnError,
|
||||
...restOptions
|
||||
} = options || {};
|
||||
return useMutation<void, APIError, DeleteMailDomainAccessProps>({
|
||||
mutationFn: deleteMailDomainAccess,
|
||||
...options,
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
|
||||
@@ -59,13 +64,27 @@ export const useDeleteMailDomainAccess = (
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
if (optionsOnSuccess) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnSuccess as unknown as (
|
||||
data: void,
|
||||
variables: DeleteMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
if (optionsOnError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnError as unknown as (
|
||||
error: APIError,
|
||||
variables: DeleteMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+24
-5
@@ -51,9 +51,14 @@ export const useUpdateMailDomainAccess = (
|
||||
options?: UseUpdateMailDomainAccessOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess: optionsOnSuccess,
|
||||
onError: optionsOnError,
|
||||
...restOptions
|
||||
} = options || {};
|
||||
return useMutation<Access, APIError, UpdateMailDomainAccessProps>({
|
||||
mutationFn: updateMailDomainAccess,
|
||||
...options,
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
|
||||
@@ -61,13 +66,27 @@ export const useUpdateMailDomainAccess = (
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_MAIL_DOMAIN],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
if (optionsOnSuccess) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnSuccess as unknown as (
|
||||
data: Access,
|
||||
variables: UpdateMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
if (optionsOnError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnError as unknown as (
|
||||
error: APIError,
|
||||
variables: UpdateMailDomainAccessProps,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './useAliases';
|
||||
export * from './useAliasesInfinite';
|
||||
export * from './useCreateAlias';
|
||||
export * from './useDeleteAlias';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Alias } from '../types';
|
||||
|
||||
export type MailDomainAliasesParams = {
|
||||
mailDomainSlug: string;
|
||||
page: number;
|
||||
ordering?: string;
|
||||
};
|
||||
|
||||
type MailDomainAliasesResponse = APIList<Alias>;
|
||||
|
||||
export const getMailDomainAliases = async ({
|
||||
mailDomainSlug,
|
||||
page,
|
||||
ordering,
|
||||
}: MailDomainAliasesParams): Promise<MailDomainAliasesResponse> => {
|
||||
let url = `mail-domains/${mailDomainSlug}/aliases/?page=${page}`;
|
||||
|
||||
if (ordering) {
|
||||
url += '&ordering=' + ordering;
|
||||
}
|
||||
|
||||
const response = await fetchAPI(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Failed to get the aliases of mail domain ${mailDomainSlug}`,
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<MailDomainAliasesResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_ALIAS = 'aliases';
|
||||
|
||||
export function useAliases(
|
||||
param: MailDomainAliasesParams,
|
||||
queryConfig?: UseQueryOptions<
|
||||
MailDomainAliasesResponse,
|
||||
APIError,
|
||||
MailDomainAliasesResponse
|
||||
>,
|
||||
) {
|
||||
return useQuery<
|
||||
MailDomainAliasesResponse,
|
||||
APIError,
|
||||
MailDomainAliasesResponse
|
||||
>({
|
||||
queryKey: [KEY_LIST_ALIAS, param],
|
||||
queryFn: () => getMailDomainAliases(param),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { KEY_LIST_ALIAS, getMailDomainAliases } from './useAliases';
|
||||
|
||||
export type MailDomainAliasesInfiniteParams = {
|
||||
mailDomainSlug: string;
|
||||
ordering?: string;
|
||||
};
|
||||
|
||||
export function useAliasesInfinite(
|
||||
param: MailDomainAliasesInfiniteParams,
|
||||
queryConfig = {},
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
initialPageParam: 1,
|
||||
queryKey: [KEY_LIST_ALIAS, param],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getMailDomainAliases({
|
||||
mailDomainSlug: param.mailDomainSlug,
|
||||
page: pageParam,
|
||||
ordering: param.ordering,
|
||||
}),
|
||||
getNextPageParam(lastPage, allPages) {
|
||||
// When there is no more page, return undefined
|
||||
if (!lastPage.next) {
|
||||
return undefined;
|
||||
}
|
||||
return allPages.length + 1;
|
||||
},
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_ALIAS } from './useAliases';
|
||||
|
||||
export interface CreateAliasParams {
|
||||
local_part: string;
|
||||
destination: string;
|
||||
mailDomainSlug: string;
|
||||
}
|
||||
|
||||
export const createAlias = async ({
|
||||
mailDomainSlug,
|
||||
...data
|
||||
}: CreateAliasParams): Promise<void> => {
|
||||
const response = await fetchAPI(`mail-domains/${mailDomainSlug}/aliases/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await errorCauses(response);
|
||||
throw new APIError('Failed to create the alias', {
|
||||
status: errorData.status,
|
||||
cause: errorData.cause as string[],
|
||||
data: errorData.data,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type UseCreateAliasParams = { mailDomainSlug: string } & UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
CreateAliasParams
|
||||
>;
|
||||
|
||||
export const useCreateAlias = (options: UseCreateAliasParams) => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess: optionsOnSuccess,
|
||||
onError: optionsOnError,
|
||||
...restOptions
|
||||
} = options;
|
||||
return useMutation<void, APIError, CreateAliasParams>({
|
||||
mutationFn: createAlias,
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
KEY_LIST_ALIAS,
|
||||
{ mailDomainSlug: variables.mailDomainSlug },
|
||||
],
|
||||
});
|
||||
if (optionsOnSuccess) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnSuccess as unknown as (
|
||||
data: void,
|
||||
variables: CreateAliasParams,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (optionsOnError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnError as unknown as (
|
||||
error: APIError,
|
||||
variables: CreateAliasParams,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_ALIAS } from './useAliases';
|
||||
|
||||
interface DeleteAliasParams {
|
||||
mailDomainSlug: string;
|
||||
localPart: string;
|
||||
}
|
||||
|
||||
export const deleteAlias = async ({
|
||||
mailDomainSlug,
|
||||
localPart,
|
||||
}: DeleteAliasParams): Promise<void> => {
|
||||
const response = await fetchAPI(
|
||||
`mail-domains/${mailDomainSlug}/aliases/delete/?local_part=${encodeURIComponent(localPart)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to delete the alias',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseDeleteAliasOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
DeleteAliasParams
|
||||
>;
|
||||
|
||||
export const useDeleteAlias = (options?: UseDeleteAliasOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess: optionsOnSuccess,
|
||||
onError: optionsOnError,
|
||||
...restOptions
|
||||
} = options || {};
|
||||
return useMutation<void, APIError, DeleteAliasParams>({
|
||||
mutationFn: deleteAlias,
|
||||
...restOptions,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_ALIAS],
|
||||
});
|
||||
if (optionsOnSuccess) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnSuccess as unknown as (
|
||||
data: void,
|
||||
variables: DeleteAliasParams,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (optionsOnError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
optionsOnError as unknown as (
|
||||
error: APIError,
|
||||
variables: DeleteAliasParams,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_ALIAS } from './useAliases';
|
||||
|
||||
interface DeleteAliasByIdParams {
|
||||
mailDomainSlug: string;
|
||||
aliasId: string;
|
||||
}
|
||||
|
||||
export const deleteAliasById = async ({
|
||||
mailDomainSlug,
|
||||
aliasId,
|
||||
}: DeleteAliasByIdParams): Promise<void> => {
|
||||
// Use aliasId (pk) directly in URL as per API lookup_field = "pk"
|
||||
const response = await fetchAPI(
|
||||
`mail-domains/${mailDomainSlug}/aliases/${aliasId}/`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to delete the alias',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseDeleteAliasByIdOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
DeleteAliasByIdParams
|
||||
>;
|
||||
|
||||
export const useDeleteAliasById = (options?: UseDeleteAliasByIdOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, DeleteAliasByIdParams>({
|
||||
mutationFn: deleteAliasById,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_ALIAS],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
options.onSuccess as unknown as (
|
||||
data: void,
|
||||
variables: DeleteAliasByIdParams,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
|
||||
(
|
||||
options.onError as unknown as (
|
||||
error: APIError,
|
||||
variables: DeleteAliasByIdParams,
|
||||
context: unknown,
|
||||
) => void
|
||||
)(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Button, Input, Tooltip } from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ModalCreateAlias } from '@/features/mail-domains/aliases/components';
|
||||
import { AliasesListView } from '@/features/mail-domains/aliases/components/panel';
|
||||
|
||||
import { MailDomain } from '../../domains/types';
|
||||
|
||||
export function AliasesView({ mailDomain }: { mailDomain: MailDomain }) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const [isCreateAliasFormVisible, setIsCreateAliasFormVisible] =
|
||||
useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const colors = colorsTokens();
|
||||
|
||||
const canCreateAlias = mailDomain.status === 'enabled';
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(event.target.value);
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
setSearchValue('');
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsCreateAliasFormVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div aria-label="Aliases panel" className="container">
|
||||
<h3 style={{ fontWeight: 700, fontSize: '18px', marginBottom: 'base' }}>
|
||||
{t('Aliases')}
|
||||
</h3>
|
||||
<div
|
||||
className="sm:block md:flex"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
gap: '1em',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ width: 'calc(100% - 245px)' }}
|
||||
className="c__input__wrapper__mobile"
|
||||
>
|
||||
<Input
|
||||
style={{ width: '100%' }}
|
||||
label={t('Search for an alias')}
|
||||
icon={<span className="material-icons">search</span>}
|
||||
rightIcon={
|
||||
searchValue && (
|
||||
<span
|
||||
className="material-icons"
|
||||
onClick={clearInput}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
clearInput();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
close
|
||||
</span>
|
||||
)
|
||||
}
|
||||
value={searchValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hidden md:flex"
|
||||
style={{
|
||||
background: colors['greyscale-200'],
|
||||
height: '32px',
|
||||
width: '1px',
|
||||
}}
|
||||
></div>
|
||||
|
||||
<div
|
||||
className="block md:hidden"
|
||||
style={{ marginBottom: '10px' }}
|
||||
></div>
|
||||
|
||||
<div>
|
||||
{mailDomain?.abilities.post ? (
|
||||
<Button
|
||||
data-testid="button-new-alias"
|
||||
aria-label={t('Create an alias in {{name}} domain', {
|
||||
name: mailDomain?.name,
|
||||
})}
|
||||
disabled={!canCreateAlias}
|
||||
onClick={() => setIsCreateAliasFormVisible(true)}
|
||||
>
|
||||
{t('New alias')}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip content={t("You don't have the correct access right")}>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="button-new-alias"
|
||||
onClick={openModal}
|
||||
disabled={!isCreateAliasFormVisible}
|
||||
>
|
||||
{t('New alias')}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AliasesListView mailDomain={mailDomain} querySearch={searchValue} />
|
||||
{isCreateAliasFormVisible && mailDomain ? (
|
||||
<ModalCreateAlias
|
||||
mailDomain={mailDomain}
|
||||
closeModal={() => setIsCreateAliasFormVisible(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
|
||||
import {
|
||||
Button,
|
||||
Loader,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { parseAPIError } from '@/api/parseAPIError';
|
||||
import {
|
||||
Box,
|
||||
HorizontalSeparator,
|
||||
Icon,
|
||||
Input,
|
||||
Text,
|
||||
TextErrors,
|
||||
} from '@/components';
|
||||
import { CustomModal } from '@/components/modal/CustomModal';
|
||||
|
||||
import { MailDomain } from '../../domains/types';
|
||||
import { useCreateAlias } from '../api';
|
||||
|
||||
const FORM_ID = 'form-create-alias';
|
||||
|
||||
export const ModalCreateAlias = ({
|
||||
mailDomain,
|
||||
closeModal,
|
||||
}: {
|
||||
mailDomain: MailDomain;
|
||||
closeModal: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const [errorCauses, setErrorCauses] = useState<string[]>([]);
|
||||
const [step] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [destinations, setDestinations] = useState<string[]>([]);
|
||||
const [newDestination, setNewDestination] = useState('');
|
||||
const [destinationError, setDestinationError] = useState<string | null>(null);
|
||||
|
||||
type AliasFormData = {
|
||||
local_part: string;
|
||||
};
|
||||
|
||||
const createAliasValidationSchema: z.ZodType<AliasFormData> = z.object({
|
||||
local_part: z
|
||||
.string()
|
||||
.regex(/^((?!@|\s)([a-zA-Z0-9.\-]))*$/, t('Invalid format'))
|
||||
.min(1, t('You must have minimum 1 character')),
|
||||
});
|
||||
|
||||
const methods = useForm<AliasFormData>({
|
||||
resolver: standardSchemaResolver(createAliasValidationSchema),
|
||||
defaultValues: {
|
||||
local_part: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const addDestination = () => {
|
||||
const trimmed = newDestination.trim();
|
||||
if (!trimmed) {
|
||||
setDestinationError(t('Please enter an email address'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(trimmed)) {
|
||||
setDestinationError(t('Please enter a valid email address'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si déjà présent
|
||||
if (destinations.includes(trimmed)) {
|
||||
setDestinationError(t('This email address is already in the list'));
|
||||
return;
|
||||
}
|
||||
|
||||
setDestinations([...destinations, trimmed]);
|
||||
setNewDestination('');
|
||||
setDestinationError(null);
|
||||
};
|
||||
|
||||
const removeDestination = (index: number) => {
|
||||
setDestinations(destinations.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDestinationKeyPress = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addDestination();
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: createAlias } = useCreateAlias({
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
});
|
||||
|
||||
const onSubmitCallback = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const isValid = await methods.trigger();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (destinations.length === 0) {
|
||||
toast(t('Please add at least one destination email'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = methods.getValues();
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorCauses([]);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const destination of destinations) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
createAlias(
|
||||
{
|
||||
local_part: data.local_part,
|
||||
destination: destination.trim(),
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
successCount++;
|
||||
resolve();
|
||||
},
|
||||
onError: (error) => {
|
||||
errorCount++;
|
||||
const causes =
|
||||
parseAPIError({
|
||||
error,
|
||||
errorParams: [
|
||||
[
|
||||
['Local part ".*" already used by a mailbox.'],
|
||||
t('This email prefix is already used by a mailbox.'),
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
['Invalid format'],
|
||||
t('Invalid format for the email prefix.'),
|
||||
undefined,
|
||||
],
|
||||
],
|
||||
serverErrorParams: [
|
||||
t(
|
||||
'The domain must be enabled to create aliases. Please check the domain status.',
|
||||
),
|
||||
undefined,
|
||||
],
|
||||
}) || [];
|
||||
|
||||
if (causes.length > 0) {
|
||||
allErrors.push(...causes);
|
||||
}
|
||||
|
||||
reject(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
// Erreur déjà gérée dans onError
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
||||
// Afficher les résultats
|
||||
if (errorCount > 0) {
|
||||
setErrorCauses(allErrors);
|
||||
}
|
||||
|
||||
if (successCount === destinations.length) {
|
||||
toast(
|
||||
t('All {{count}} alias(es) created successfully!', {
|
||||
count: successCount,
|
||||
}),
|
||||
VariantType.SUCCESS,
|
||||
{ duration: 4000 },
|
||||
);
|
||||
closeModal();
|
||||
} else if (successCount > 0) {
|
||||
toast(
|
||||
t('{{success}} alias(es) created, {{errors}} failed', {
|
||||
success: successCount,
|
||||
errors: errorCount,
|
||||
}),
|
||||
VariantType.WARNING,
|
||||
{ duration: 5000 },
|
||||
);
|
||||
} else {
|
||||
toast(t('Failed to create aliases'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: t('New alias'),
|
||||
content: (
|
||||
<FormProvider {...methods}>
|
||||
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={(e) => {
|
||||
void onSubmitCallback(e);
|
||||
}}
|
||||
>
|
||||
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
|
||||
<Text $size="md" $weight="bold">
|
||||
{t('Alias configuration')}
|
||||
</Text>
|
||||
<Text $theme="greyscale" $variation="600">
|
||||
{t(
|
||||
'An alias allows you to redirect emails to one or more addresses.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
$padding="md"
|
||||
style={{
|
||||
position: 'relative',
|
||||
alignItems: 'end',
|
||||
gap: '20px',
|
||||
flexDirection: 'row',
|
||||
alignContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="local_part"
|
||||
control={methods.control}
|
||||
render={({ field }) => (
|
||||
<Box $align="center">
|
||||
<Input
|
||||
{...field}
|
||||
label={t('Name of the alias')}
|
||||
required
|
||||
placeholder={t('contact')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
top: '58px',
|
||||
left: '210px',
|
||||
}}
|
||||
>
|
||||
<Text className="mb-8" $weight="500">
|
||||
@{mailDomain.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<HorizontalSeparator $withPadding={true} />
|
||||
|
||||
<Box $padding={{ horizontal: 'md' }}>
|
||||
<Box $margin={{ top: 'base', bottom: 'base' }} $gap="12px">
|
||||
<Text $size="sm" $weight="500">
|
||||
{t('Destination email addresses')}
|
||||
</Text>
|
||||
|
||||
<Box $gap="4px">
|
||||
<Box $direction="row" $gap="8px" $align="end">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Input
|
||||
value={newDestination}
|
||||
onChange={(e) => {
|
||||
setNewDestination(e.target.value);
|
||||
setDestinationError(null);
|
||||
}}
|
||||
onKeyPress={handleDestinationKeyPress}
|
||||
label={t('Add destination email')}
|
||||
placeholder=""
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addDestination}
|
||||
disabled={!newDestination.trim()}
|
||||
>
|
||||
{t('Add destination')}
|
||||
</Button>
|
||||
</Box>
|
||||
{destinationError && (
|
||||
<Text $theme="warning" $size="sm">
|
||||
{destinationError}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Destinations Array */}
|
||||
{destinations.length > 0 && (
|
||||
<Box
|
||||
$margin={{ top: 'md' }}
|
||||
style={{
|
||||
border:
|
||||
'1px solid var(--c--contextuals--border--surface--primary)',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{ width: '100%', borderCollapse: 'collapse' }}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
paddingBottom: '12px',
|
||||
borderBottom:
|
||||
'1px solid var(--c--contextuals--border--surface--primary)',
|
||||
}}
|
||||
>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{t('Email address')}
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
paddingBottom: '12px',
|
||||
textAlign: 'right',
|
||||
width: '80px',
|
||||
}}
|
||||
>
|
||||
{t('Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{destinations.map((destination, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={{
|
||||
paddingBottom: '12px',
|
||||
borderBottom:
|
||||
index < destinations.length - 1
|
||||
? '1px solid var(--c--contextuals--border--surface--primary)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<Text $size="sm">{destination}</Text>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
type="button"
|
||||
color="tertiary"
|
||||
onClick={() => removeDestination(index)}
|
||||
aria-label={t('Remove destination')}
|
||||
icon={<Icon iconName="delete" />}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</FormProvider>
|
||||
),
|
||||
leftAction: (
|
||||
<Button color="secondary" onClick={closeModal}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
),
|
||||
rightAction: (
|
||||
<Button
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={
|
||||
!methods.formState.isValid ||
|
||||
destinations.length === 0 ||
|
||||
isSubmitting
|
||||
}
|
||||
>
|
||||
{isSubmitting ? t('Creating...') : t('Create alias')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div id="modal-new-alias">
|
||||
<CustomModal
|
||||
isOpen
|
||||
hideCloseButton
|
||||
step={step}
|
||||
totalSteps={steps.length}
|
||||
leftActions={steps[step].leftAction}
|
||||
rightActions={steps[step].rightAction}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={steps[step].title}
|
||||
onClose={closeModal}
|
||||
closeOnEsc
|
||||
closeOnClickOutside
|
||||
>
|
||||
{steps[step].content}
|
||||
{isSubmitting && (
|
||||
<Box $align="center" $padding="md">
|
||||
<Loader />
|
||||
<Text $theme="greyscale" $variation="600" $margin={{ top: 'sm' }}>
|
||||
{t('Creating alias...')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</CustomModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
import {
|
||||
Button,
|
||||
Loader,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { parseAPIError } from '@/api/parseAPIError';
|
||||
import {
|
||||
Box,
|
||||
HorizontalSeparator,
|
||||
Icon,
|
||||
Input,
|
||||
Text,
|
||||
TextErrors,
|
||||
} from '@/components';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { CustomModal } from '@/components/modal/CustomModal';
|
||||
|
||||
import { MailDomain } from '../../domains/types';
|
||||
import { useCreateAlias } from '../api/useCreateAlias';
|
||||
import { useDeleteAlias } from '../api/useDeleteAlias';
|
||||
import { useDeleteAliasById } from '../api/useDeleteAliasById';
|
||||
import { AliasGroup } from '../types';
|
||||
|
||||
const FORM_ID = 'form-edit-alias';
|
||||
|
||||
export const ModalEditAlias = ({
|
||||
mailDomain,
|
||||
aliasGroup,
|
||||
closeModal,
|
||||
}: {
|
||||
mailDomain: MailDomain;
|
||||
aliasGroup: AliasGroup;
|
||||
closeModal: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const [errorCauses, setErrorCauses] = useState<string[]>([]);
|
||||
const [step] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [destinations, setDestinations] = useState<string[]>([]);
|
||||
const [newDestination, setNewDestination] = useState('');
|
||||
const [destinationError, setDestinationError] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setDestinations([...aliasGroup.destinations]);
|
||||
}, [aliasGroup]);
|
||||
|
||||
const addDestination = async () => {
|
||||
const trimmed = newDestination.trim();
|
||||
if (!trimmed) {
|
||||
setDestinationError(t('Please enter an email address'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(trimmed)) {
|
||||
setDestinationError(t('Please enter a valid email address'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Email already in list
|
||||
if (destinations.includes(trimmed)) {
|
||||
setDestinationError(t('This email address is already in the list'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check domain enabled
|
||||
if (mailDomain.status !== 'enabled') {
|
||||
setDestinationError(
|
||||
t(
|
||||
'The domain must be enabled to add destinations. Current status: {{status}}',
|
||||
{
|
||||
status: mailDomain.status,
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setDestinationError(undefined);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
createAlias(
|
||||
{
|
||||
local_part: aliasGroup.local_part,
|
||||
destination: trimmed,
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast(t('Destination added successfully'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
setDestinations([...destinations, trimmed]);
|
||||
setNewDestination('');
|
||||
resolve();
|
||||
},
|
||||
onError: (error) => {
|
||||
const causes =
|
||||
parseAPIError({
|
||||
error,
|
||||
errorParams: [
|
||||
[
|
||||
['Local part ".*" already used by a mailbox.'],
|
||||
t('This email prefix is already used by a mailbox.'),
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
['Invalid format'],
|
||||
t('Invalid format for the email prefix.'),
|
||||
undefined,
|
||||
],
|
||||
],
|
||||
serverErrorParams: [
|
||||
t(
|
||||
'The domain must be enabled to add destinations. Please check the domain status.',
|
||||
),
|
||||
undefined,
|
||||
],
|
||||
}) || [];
|
||||
|
||||
if (causes.length > 0) {
|
||||
setDestinationError(causes[0]);
|
||||
} else {
|
||||
setDestinationError(
|
||||
t('Failed to add destination. Please try again.'),
|
||||
);
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDestination = (destination: string) => {
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: t('Remove destination'),
|
||||
message: t(
|
||||
'Are you sure you want to remove {{destination}} from this alias?',
|
||||
{ destination },
|
||||
),
|
||||
onConfirm: () => {
|
||||
setConfirmModal({ ...confirmModal, isOpen: false });
|
||||
void handleRemoveDestination(destination);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveDestination = async (destination: string) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const aliasId = aliasGroup.destinationIds[destination];
|
||||
if (!aliasId) {
|
||||
toast(
|
||||
t('Failed to find alias ID for this destination'),
|
||||
VariantType.ERROR,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
deleteAliasById(
|
||||
{
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
aliasId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast(
|
||||
t('Destination removed successfully'),
|
||||
VariantType.SUCCESS,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
setDestinations(destinations.filter((d) => d !== destination));
|
||||
resolve();
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
const causes =
|
||||
parseAPIError({
|
||||
error,
|
||||
errorParams: [],
|
||||
serverErrorParams: [
|
||||
t(
|
||||
'An error occurred while removing the destination. Please try again.',
|
||||
),
|
||||
undefined,
|
||||
],
|
||||
}) || [];
|
||||
|
||||
if (causes.length > 0) {
|
||||
setDestinationError(causes[0]);
|
||||
} else {
|
||||
toast(t('Failed to remove destination'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAllDestinations = () => {
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: t('Delete this alias'),
|
||||
message: t(
|
||||
'Are you sure you want to remove all destinations from this alias? This action cannot be undone.',
|
||||
),
|
||||
onConfirm: () => {
|
||||
setConfirmModal({ ...confirmModal, isOpen: false });
|
||||
void removeAllDestinations();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const removeAllDestinations = async () => {
|
||||
if (destinations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorCauses([]);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
deleteAlias(
|
||||
{
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
localPart: aliasGroup.local_part,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast(t('Alias deleted successfully'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
setDestinations([]);
|
||||
resolve();
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
const causes =
|
||||
parseAPIError({
|
||||
error,
|
||||
errorParams: [],
|
||||
serverErrorParams: [
|
||||
t(
|
||||
'An error occurred while deleting the alias. Please try again.',
|
||||
),
|
||||
undefined,
|
||||
],
|
||||
}) || [];
|
||||
|
||||
if (causes.length > 0) {
|
||||
setErrorCauses(causes);
|
||||
} else {
|
||||
toast(t('Failed to delete alias'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDestinationKeyPress = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void addDestination();
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: createAlias } = useCreateAlias({
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
});
|
||||
|
||||
const { mutate: deleteAliasById } = useDeleteAliasById();
|
||||
const { mutate: deleteAlias } = useDeleteAlias();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: t('Manage alias'),
|
||||
content: (
|
||||
<>
|
||||
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void addDestination();
|
||||
}}
|
||||
>
|
||||
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
|
||||
<Text $size="md" $weight="bold">
|
||||
{t('Alias configuration')}
|
||||
</Text>
|
||||
<Text $theme="greyscale" $variation="600">
|
||||
{t('Manage the destination email addresses for this alias.')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
$padding="md"
|
||||
style={{
|
||||
position: 'relative',
|
||||
alignItems: 'end',
|
||||
gap: '20px',
|
||||
flexDirection: 'row',
|
||||
alignContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Input
|
||||
value={aliasGroup.local_part}
|
||||
label={t('Name of the alias')}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
top: '58px',
|
||||
left: '210px',
|
||||
}}
|
||||
>
|
||||
<Text className="mb-8" $weight="500">
|
||||
@{mailDomain.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<HorizontalSeparator $withPadding={true} />
|
||||
|
||||
<Box $padding={{ horizontal: 'md' }}>
|
||||
<Box $margin={{ top: 'base', bottom: 'base' }} $gap="12px">
|
||||
<Text $size="sm" $weight="500">
|
||||
{t('Destination email addresses')}
|
||||
</Text>
|
||||
|
||||
<Box $gap="4px">
|
||||
<Box $direction="row" $gap="8px" $align="end">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Input
|
||||
value={newDestination}
|
||||
onChange={(e) => {
|
||||
setNewDestination(e.target.value);
|
||||
setDestinationError(undefined);
|
||||
}}
|
||||
onKeyPress={handleDestinationKeyPress}
|
||||
error={destinationError}
|
||||
label={t('Add destination email')}
|
||||
placeholder={t('john.appleseed@example.fr')}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={!newDestination.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('Adding...') : t('Add')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Tableau des destinations */}
|
||||
{destinations.length > 0 && (
|
||||
<Box
|
||||
$margin={{ top: 'md' }}
|
||||
$css={`
|
||||
table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
table tbody tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<table
|
||||
style={{ width: '100%', borderCollapse: 'collapse' }}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
paddingBottom: '12px',
|
||||
borderBottom:
|
||||
'1px solid var(--c--contextuals--border--surface--primary)',
|
||||
}}
|
||||
>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{t('Email address')}
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
paddingBottom: '12px',
|
||||
textAlign: 'right',
|
||||
width: '80px',
|
||||
}}
|
||||
>
|
||||
{t('Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{destinations.map((destination, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={{
|
||||
paddingBottom: '12px',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<td style={{ paddingLeft: '12px' }}>
|
||||
<Text $size="sm">{destination}</Text>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
type="button"
|
||||
color="tertiary"
|
||||
onClick={() => removeDestination(destination)}
|
||||
aria-label={t('Remove destination')}
|
||||
icon={<Icon iconName="delete" />}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</>
|
||||
),
|
||||
leftAction: (
|
||||
<Button color="secondary" onClick={closeModal}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
),
|
||||
rightAction: (
|
||||
<Box $direction="row" $gap="6px">
|
||||
{destinations.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="danger"
|
||||
onClick={handleRemoveAllDestinations}
|
||||
disabled={isSubmitting}
|
||||
icon={
|
||||
<Icon iconName="delete" $theme="greyscale" $variation="000" />
|
||||
}
|
||||
>
|
||||
{t('Delete this alias')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div id="modal-edit-alias">
|
||||
<CustomModal
|
||||
isOpen
|
||||
hideCloseButton
|
||||
step={step}
|
||||
totalSteps={steps.length}
|
||||
leftActions={steps[step].leftAction}
|
||||
rightActions={steps[step].rightAction}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={steps[step].title}
|
||||
onClose={closeModal}
|
||||
closeOnEsc
|
||||
closeOnClickOutside
|
||||
>
|
||||
{steps[step].content}
|
||||
{isSubmitting && (
|
||||
<Box $align="center" $padding="md">
|
||||
<Loader />
|
||||
<Text $theme="greyscale" $variation="600" $margin={{ top: 'sm' }}>
|
||||
{t('Updating alias...')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</CustomModal>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={confirmModal.isOpen}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
leftActions={
|
||||
<Button
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => setConfirmModal({ ...confirmModal, isOpen: false })}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
rightActions={
|
||||
<Button
|
||||
color="danger"
|
||||
fullWidth
|
||||
onClick={confirmModal.onConfirm}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={confirmModal.title}
|
||||
onClose={() => setConfirmModal({ ...confirmModal, isOpen: false })}
|
||||
>
|
||||
<Box $padding="md">
|
||||
<Text>{confirmModal.message}</Text>
|
||||
</Box>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './AliasesView';
|
||||
export * from './ModalCreateAlias';
|
||||
export * from './ModalEditAlias';
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { Button, DataGrid, SortModel } from '@openfun/cunningham-react';
|
||||
import type { InfiniteData } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { Alias, AliasGroup } from '@/features/mail-domains/aliases/types';
|
||||
import { MailDomain } from '@/features/mail-domains/domains';
|
||||
|
||||
import { useAliasesInfinite } from '../../api/useAliasesInfinite';
|
||||
import { ModalEditAlias } from '../ModalEditAlias';
|
||||
|
||||
type MailDomainAliasesResponse = {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: Alias[];
|
||||
};
|
||||
|
||||
interface AliasesListViewProps {
|
||||
mailDomain: MailDomain;
|
||||
querySearch: string;
|
||||
}
|
||||
|
||||
type SortModelItem = {
|
||||
field: string;
|
||||
sort: 'asc' | 'desc' | null;
|
||||
};
|
||||
|
||||
function formatSortModel(sortModel: SortModelItem) {
|
||||
return sortModel.sort === 'desc' ? `-${sortModel.field}` : sortModel.field;
|
||||
}
|
||||
|
||||
export function AliasesListView({
|
||||
mailDomain,
|
||||
querySearch,
|
||||
}: AliasesListViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { userData } = useAuthStore();
|
||||
const [editingAliasGroup, setEditingAliasGroup] = useState<AliasGroup | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [sortModel] = useState<SortModel>([]);
|
||||
|
||||
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useAliasesInfinite({
|
||||
mailDomainSlug: mailDomain.slug,
|
||||
ordering,
|
||||
}) as {
|
||||
data: InfiniteData<MailDomainAliasesResponse, number> | undefined;
|
||||
isLoading: boolean;
|
||||
error: { cause?: string[] };
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean | undefined;
|
||||
isFetchingNextPage: boolean;
|
||||
};
|
||||
|
||||
const aliasGroups: AliasGroup[] = useMemo(() => {
|
||||
if (!mailDomain || !data?.pages?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const grouped = new Map<string, AliasGroup>();
|
||||
|
||||
data.pages.forEach((page) => {
|
||||
page.results.forEach((alias: Alias) => {
|
||||
const email = `${alias.local_part}@${mailDomain.name}`;
|
||||
const existing = grouped.get(alias.local_part);
|
||||
if (existing) {
|
||||
if (!existing.destinations.includes(alias.destination)) {
|
||||
existing.destinations.push(alias.destination);
|
||||
existing.destinationIds[alias.destination] = alias.id;
|
||||
existing.count_destinations = existing.destinations.length;
|
||||
}
|
||||
} else {
|
||||
const destinationIds: Record<string, string> = {};
|
||||
destinationIds[alias.destination] = alias.id;
|
||||
grouped.set(alias.local_part, {
|
||||
id: alias.local_part,
|
||||
email,
|
||||
local_part: alias.local_part,
|
||||
destinations: [alias.destination],
|
||||
destinationIds,
|
||||
count_destinations: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}, [data, mailDomain]);
|
||||
|
||||
const filteredAliases = useMemo(() => {
|
||||
if (!querySearch) {
|
||||
return aliasGroups;
|
||||
}
|
||||
const lowerCaseSearch = querySearch.toLowerCase();
|
||||
return aliasGroups.filter(
|
||||
(alias) =>
|
||||
alias.email.toLowerCase().includes(lowerCaseSearch) ||
|
||||
alias.destinations.some((dest) =>
|
||||
dest.toLowerCase().includes(lowerCaseSearch),
|
||||
),
|
||||
);
|
||||
}, [querySearch, aliasGroups]);
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage) {
|
||||
return;
|
||||
}
|
||||
const ref = loadMoreRef.current;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 1 },
|
||||
);
|
||||
if (ref) {
|
||||
observer.observe(ref);
|
||||
}
|
||||
return () => {
|
||||
if (ref) {
|
||||
observer.unobserve(ref);
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <TextErrors causes={error.cause ?? []} />}
|
||||
|
||||
{!filteredAliases.length && (
|
||||
<Text $align="center" $size="small" $padding={{ top: 'base' }}>
|
||||
{t('No alias was created with this mail domain.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{filteredAliases && filteredAliases.length ? (
|
||||
<>
|
||||
<DataGrid
|
||||
aria-label="aliaslist"
|
||||
rows={filteredAliases}
|
||||
columns={[
|
||||
{
|
||||
field: 'email',
|
||||
headerName: `${t('Alias')} • ${filteredAliases.length}`,
|
||||
renderCell: ({ row }) => (
|
||||
<Text $weight="400" $theme="greyscale">
|
||||
{row.email}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'destinations',
|
||||
headerName: t('Destinations'),
|
||||
enableSorting: false,
|
||||
renderCell: ({ row }) => (
|
||||
<Text $weight="500" $theme="greyscale">
|
||||
{row.count_destinations} destination
|
||||
{row.count_destinations > 1 ? 's' : ''}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
renderCell: ({ row }) => {
|
||||
// Check if user can edit
|
||||
const isOwnerOrAdmin =
|
||||
mailDomain.abilities?.patch || mailDomain.abilities?.put;
|
||||
const isAliasDestination = row.destinations.some(
|
||||
(dest) => dest === userData?.email,
|
||||
);
|
||||
const canEdit = isOwnerOrAdmin || isAliasDestination;
|
||||
|
||||
return (
|
||||
<Box $direction="row" $gap="sm" $align="center">
|
||||
{canEdit && (
|
||||
<Button
|
||||
color="tertiary"
|
||||
onClick={() => setEditingAliasGroup(row)}
|
||||
style={{
|
||||
fontWeight: '500',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{t('Manage')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{isFetchingNextPage && <div>{t('Loading more...')}</div>}
|
||||
</>
|
||||
) : null}
|
||||
<div ref={loadMoreRef} />
|
||||
{editingAliasGroup && (
|
||||
<ModalEditAlias
|
||||
mailDomain={mailDomain}
|
||||
aliasGroup={editingAliasGroup}
|
||||
closeModal={() => setEditingAliasGroup(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AliasesListView';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user