Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 617000ccf8 | |||
| d75605b280 | |||
| 230ed75d8d | |||
| 5ac3454c5a | |||
| bbac34b62a | |||
| 38508c272e | |||
| 10a81f14e6 | |||
| a08689a64d | |||
| 30229e11f9 | |||
| d11d1f3bd0 | |||
| 6a22169dae | |||
| 6e7a6e9d51 | |||
| 95fb476041 | |||
| 6b4ea1a2e7 | |||
| be55d6ea09 | |||
| 9c9216bb51 | |||
| 017f52a0dc | |||
| d635c484ae | |||
| 0e48bc0f90 | |||
| f243a2423f | |||
| 763eb254a8 | |||
| 9b613e63a9 | |||
| 6b7f00651f | |||
| fe249c5b6f | |||
| 579657afa4 | |||
| ce21a7552b | |||
| 4a63a26135 | |||
| 8e4472befc | |||
| 315a6ab931 | |||
| 2894b9a999 | |||
| aea15292ee | |||
| de46a50e8d | |||
| e6ed3c3be2 | |||
| f04c8bc6aa | |||
| ae05b430db | |||
| c98d173c0a | |||
| dddc281778 | |||
| bb2279b6d5 | |||
| 5420b20f43 | |||
| fbb2accefb | |||
| 75008d3e9a | |||
| c4c3e9de96 | |||
| 00816e097c | |||
| 976c955126 | |||
| 2967f1bff2 | |||
| a15d548ac4 | |||
| 7c6b3307fa | |||
| 5ded297df6 | |||
| b69ce001c8 | |||
| c0cd136618 | |||
| ee5a785d43 | |||
| 3d7dfa019c | |||
| cc9303bc9c | |||
| 34341e6f74 | |||
| 01abc66e59 | |||
| 55d7e846d8 | |||
| 232ea97d87 | |||
| a7afa68afa | |||
| 932ffb3bcb | |||
| d0254ce963 | |||
| cd1dcf11d5 | |||
| 59468aaa12 | |||
| dd8bd2a89b | |||
| cc86a3bd61 | |||
| 7f31a2b820 | |||
| aaca8819b3 | |||
| 77cc64a6c7 | |||
| aaad48480a | |||
| 00dafd4b15 | |||
| 864702d0ee | |||
| 29904ef7b6 | |||
| ba30b1d3ee | |||
| 9503b073b6 | |||
| e4aed82ff2 | |||
| 25898bbb64 | |||
| b5d8e92d1e | |||
| 237d64b4c5 | |||
| 0d157d3f2b | |||
| d291e55a9e | |||
| e7aebfe59e | |||
| bd6cd59df6 | |||
| 874ce18134 | |||
| 4fe74733a5 | |||
| 4b47f80cab | |||
| ba631fafb9 | |||
| ce15e8a3ed | |||
| 55dc342a8b | |||
| 05c8f636dd |
@@ -0,0 +1,22 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "people,secrets"
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make create-env-files
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
|
||||
- name: Build and Start Docker Servers
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
run: |
|
||||
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make run
|
||||
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
creation_rules:
|
||||
- path_regex: ./*
|
||||
key_groups:
|
||||
- age:
|
||||
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
|
||||
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
|
||||
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
|
||||
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
|
||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
|
||||
+107
-1
@@ -8,6 +8,104 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.2] - 2024-11-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(mail) fix display button on outlook
|
||||
|
||||
## [1.4.1] - 2024-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(frontend) fix MailDomainsLayout
|
||||
|
||||
## [1.4.0] - 2024-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add tabs inside #466
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✏️(mail) fix typo into mailbox creation email
|
||||
- 🐛(sentry) fix duplicated sentry errors #479
|
||||
- 🐛(script) improve and fix release script
|
||||
|
||||
## [1.3.1] - 2024-10-18
|
||||
|
||||
## [1.3.0] - 2024-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add RELEASE version on config endpoint #459
|
||||
- ✨(backend) manage roles on domain admin view
|
||||
- ✨(frontend) show version number in footer #369
|
||||
|
||||
### Changed
|
||||
|
||||
- 🛂(backend) match email if no existing user matches the sub
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💄(mail) improve mailbox creation email #462
|
||||
- 🐛(frontend) fix update accesses form #448
|
||||
- 🛂(backend) do not duplicate user when disabled
|
||||
|
||||
## [1.2.1] - 2024-10-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔧(mail) use new scaleway email gateway #435
|
||||
|
||||
|
||||
## [1.2.0] - 2024-09-30
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(ci) add helmfile linter and fix argocd sync #424
|
||||
- ✨(domains) add endpoint to list and retrieve domain accesses #404
|
||||
- 🍱(dev) embark dimail-api as container #366
|
||||
- ✨(dimail) allow la regie to request a token for another user #416
|
||||
- ✨(frontend) show username on AccountDropDown #412
|
||||
- 🥅(frontend) improve add & update group forms error handling #387
|
||||
- ✨(frontend) allow group members filtering #363
|
||||
- ✨(mailbox) send new mailbox confirmation email #397
|
||||
- ✨(domains) domain accesses update API #423
|
||||
- ✨(backend) domain accesses create API #428
|
||||
- 🥅(frontend) catch new errors on mailbox creation #392
|
||||
- ✨(api) domain accesses delete API #433
|
||||
- ✨(frontend) add mail domain access management #413
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿️(frontend) fix left nav panel #396
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #432
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(serializers) move business logic to serializers #414
|
||||
|
||||
## [1.1.0] - 2024-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(monitoring) configure sentry monitoring #378
|
||||
- 🥅(frontend) improve api error handling #355
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗃️(models) move dimail 'secret' to settings #372
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
|
||||
- 💬(frontend) fix group member removal text #382
|
||||
- 💬(frontend) fix add mail domain text #382
|
||||
- 🐛(frontend) fix keyboard navigation #379
|
||||
- 🐛(frontend) fix add mail domain form submission #355
|
||||
|
||||
## [1.0.2] - 2024-08-30
|
||||
|
||||
### Added
|
||||
@@ -32,7 +130,15 @@ and this project adheres to
|
||||
- ✨(domains) create and manage domains on admin + API
|
||||
- ✨(domains) mailbox creation + link to email provisioning API
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.0.2...main
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.4.2...main
|
||||
[1.4.2]: https://github.com/numerique-gouv/people/releases/v1.4.2
|
||||
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
|
||||
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
|
||||
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
|
||||
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
|
||||
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
|
||||
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
|
||||
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
|
||||
|
||||
@@ -88,6 +88,7 @@ bootstrap: \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build \
|
||||
dimail-setup-db \
|
||||
install-front-desk
|
||||
.PHONY: bootstrap
|
||||
|
||||
@@ -109,6 +110,7 @@ run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d app-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d keycloak
|
||||
@$(COMPOSE) up -d dimail
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_KC_DB)
|
||||
@$(WAIT_DB)
|
||||
@@ -129,6 +131,12 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
reset-dimail-container:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
@$(MAKE) setup-dimail-db
|
||||
.PHONY: reset-dimail-container
|
||||
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
@@ -272,6 +280,13 @@ i18n-generate-and-upload: \
|
||||
crowdin-upload
|
||||
.PHONY: i18n-generate-and-upload
|
||||
|
||||
# -- INTEROPERABILTY
|
||||
# -- Dimail configuration
|
||||
|
||||
dimail-setup-db:
|
||||
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
|
||||
@$(MANAGE) setup_dimail_db
|
||||
.PHONY: dimail-setup-db
|
||||
|
||||
# -- Mail generator
|
||||
|
||||
@@ -343,3 +358,7 @@ start-kind: ## Create the kubernetes cluster
|
||||
tilt-up: ## start tilt - k8s local development
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: tilt-up
|
||||
|
||||
release: ## helper for release and deployment
|
||||
python scripts/release.py
|
||||
.PHONY: release
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# People
|
||||
|
||||
People is an application to handle users and teams.
|
||||
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
|
||||
|
||||
As of today, this project is **not yet ready for production**. Expect breaking changes.
|
||||
|
||||
People is built on top of [Django Rest
|
||||
It is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/).
|
||||
|
||||
All interoperabilities will be described in `docs/interoperability`.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisite
|
||||
@@ -25,7 +25,7 @@ $ docker compose -v
|
||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
||||
> avoided by assigning your user to the `docker` group.
|
||||
|
||||
### Project bootstrap
|
||||
### Bootstrap project
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
@@ -38,7 +38,7 @@ database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
Your Docker services should now be up and running! 🎉
|
||||
|
||||
Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
|
||||
@@ -46,13 +46,7 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
$ make run
|
||||
```
|
||||
|
||||
### Adding content
|
||||
|
||||
You can create a basic demo site by running:
|
||||
|
||||
$ make demo
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
You can check all available Make rules using:
|
||||
|
||||
```bash
|
||||
$ make help
|
||||
@@ -71,6 +65,23 @@ $ make superuser
|
||||
|
||||
You can then login with sub `admin` and password `admin`.
|
||||
|
||||
### Adding demo content
|
||||
|
||||
You can create a basic demo site by running:
|
||||
|
||||
```bash
|
||||
$ make demo
|
||||
```
|
||||
|
||||
### Setting dimail database
|
||||
|
||||
To ease local development when working on interoperability between people and dimail, we embark dimail-api in a container running in "fake" mode.
|
||||
|
||||
To populate dimail local database with users/domains/permissions needed for basic development:
|
||||
- log in with "people" user
|
||||
- run `make dimail-setup-db`
|
||||
|
||||
|
||||
### Run frontend
|
||||
|
||||
Run the front with:
|
||||
@@ -79,14 +90,10 @@ Run the front with:
|
||||
$ make run-front-desk
|
||||
```
|
||||
|
||||
Then access at
|
||||
[http://localhost:3000](http://localhost:3000)
|
||||
|
||||
Then access [http://localhost:3000](http://localhost:3000) with :
|
||||
user: people
|
||||
|
||||
password: people
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
|
||||
+8
-2
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
@@ -168,3 +166,11 @@ services:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
|
||||
dimail:
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:latest
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
DIMAIL_JWT_SECRET: fake_jwt_secret
|
||||
ports:
|
||||
- "8001:8000"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# dimail
|
||||
|
||||
## What is dimail ?
|
||||
|
||||
The mailing solution provided in La Suite is [Open-XChange](https://www.open-xchange.com/) (OX).
|
||||
OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
|
||||
|
||||
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
|
||||
|
||||
## Architectural links of dimail
|
||||
|
||||
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users and accesses.
|
||||
|
||||
### Domains
|
||||
|
||||
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered valid and mailboxes can be created on it.
|
||||
|
||||
### Users
|
||||
|
||||
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
|
||||
|
||||
### Accesses
|
||||
|
||||
As for People, an access - a permissions (or "allows" in dimail) - grants an user permission to create objects on a domain.
|
||||
|
||||
Permissions requests are sent automatically upon :
|
||||
- dimail database initialisation:
|
||||
+ permission for dimail user People to create users and domains
|
||||
- domain creation :
|
||||
+ permission for dimail user People to manage domain
|
||||
+ permission for new owner on new domain
|
||||
- user creation:
|
||||
+ permission for People to manage user
|
||||
- access creation, if owner or admin:
|
||||
+ permission for this user to manage domain
|
||||
@@ -0,0 +1,115 @@
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from utils import run_command
|
||||
|
||||
|
||||
RELEASE_KINDS = {'p': 'patch', 'm': 'minor', 'mj': 'major'}
|
||||
|
||||
|
||||
def update_files(version):
|
||||
"""Update all files needed with new release version"""
|
||||
# pyproject.toml
|
||||
sys.stdout.write("Update pyproject.toml...\n")
|
||||
path = "src/backend/pyproject.toml"
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith("version = "):
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
# helm files
|
||||
sys.stdout.write("Update helm files...\n")
|
||||
for env in ["preprod", "production"]:
|
||||
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "tag:" in line:
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
# frontend package.json
|
||||
sys.stdout.write("Update package.json...\n")
|
||||
files_to_modify = []
|
||||
filename = "package.json"
|
||||
for root, _dir, files in os.walk("src/frontend"):
|
||||
if filename in files and "node_modules" not in root and ".next" not in root:
|
||||
files_to_modify.append(os.path.join(root, filename))
|
||||
|
||||
for path in files_to_modify:
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "version" in line:
|
||||
lines[index] = re.sub(r'"version": \"(.*?)\"', f'"version": "{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
return
|
||||
|
||||
|
||||
def update_changelog(path, version):
|
||||
"""Update changelog file with release info
|
||||
"""
|
||||
sys.stdout.write("Update CHANGELOG...\n")
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "## [Unreleased]" in line:
|
||||
today = datetime.date.today()
|
||||
lines.insert(index + 1, f"\n## [{version}] - {today}\n")
|
||||
if line.startswith("[unreleased]"):
|
||||
last_version = lines[index + 1].split("]")[0][1:]
|
||||
new_unreleased_line = line.replace(last_version, version)
|
||||
new_release_line = lines[index + 1].replace(last_version, version)
|
||||
lines[index] = new_unreleased_line
|
||||
lines.insert(index + 1, new_release_line)
|
||||
break
|
||||
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def prepare_release(version, kind):
|
||||
sys.stdout.write('Let\'s go to create branch to release\n')
|
||||
branch_to_release = f"release/{version}"
|
||||
run_command(f"git checkout -b {branch_to_release}", shell=True)
|
||||
run_command("git pull --rebase origin main", shell=True)
|
||||
|
||||
update_changelog("CHANGELOG.md", version)
|
||||
update_files(version)
|
||||
|
||||
run_command("git add CHANGELOG.md", shell=True)
|
||||
run_command("git add src/", shell=True)
|
||||
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
|
||||
|
||||
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {branch_to_release}", shell=True)
|
||||
sys.stdout.write(f"""
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR and merge code to main than tag version
|
||||
>> git tag -a v{version}
|
||||
>> git push origin v{version}
|
||||
--> Please check docker image: https://hub.docker.com/r/lasuite/people-backend/tags
|
||||
>> git tag -d preprod
|
||||
>> git tag preprod
|
||||
>> git push -f origin preprod
|
||||
--> Now please generate release on github interface for the current tag v{version}
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
version, kind = None, None
|
||||
while not version:
|
||||
version = input("Enter your release version:")
|
||||
while kind not in RELEASE_KINDS:
|
||||
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
|
||||
prepare_release(version, kind)
|
||||
@@ -0,0 +1,14 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
|
||||
if msg is None:
|
||||
msg = f"# Running: {cmd}"
|
||||
if stdout is not None:
|
||||
stdout.write(msg + '\n')
|
||||
if stdout != sys.stdout:
|
||||
sys.stdout(msg)
|
||||
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
|
||||
if stdout is not None:
|
||||
stdout.flush()
|
||||
+1
-1
Submodule secrets updated: a31bc360ab...b7ab5f1411
@@ -506,7 +506,7 @@ class ConfigView(views.APIView):
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = ["LANGUAGES", "FEATURES"]
|
||||
array_settings = ["LANGUAGES", "FEATURES", "RELEASE"]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Authentication Backends for the People core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -9,6 +10,8 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -48,7 +51,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
"""Return a User based on userinfo. Create a new user if no match is found.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
@@ -64,30 +67,30 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
# Compute user name from OIDC name fields as defined in settings
|
||||
names_list = [
|
||||
user_info[field]
|
||||
for field in settings.USER_OIDC_FIELDS_TO_NAME
|
||||
if user_info.get(field)
|
||||
]
|
||||
user_info["name"] = " ".join(names_list) or None
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
full_name = self.compute_full_name(user_info)
|
||||
email = user_info.get("email")
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
}
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if sub is None:
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
try:
|
||||
user = self.UserModel.objects.get(sub=sub, is_active=True)
|
||||
except self.UserModel.DoesNotExist:
|
||||
if self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
else:
|
||||
email = user_info.get("email")
|
||||
name = user_info.get("name")
|
||||
if email and email != user.email or name and name != user.name:
|
||||
self.UserModel.objects.filter(sub=sub).update(email=email, name=name)
|
||||
# if sub is absent, try matching on email
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
self.update_user_if_needed(user, claims)
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
|
||||
|
||||
return user
|
||||
|
||||
@@ -105,3 +108,32 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
email=claims.get("email"),
|
||||
name=claims.get("name"),
|
||||
)
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
name_fields = settings.USER_OIDC_FIELDS_TO_NAME
|
||||
full_name = " ".join(
|
||||
user_info[field] for field in name_fields if user_info.get(field)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
has_changed = any(
|
||||
value and value != getattr(user, key) for key, value in claims.items()
|
||||
)
|
||||
if has_changed:
|
||||
updated_claims = {key: value for key, value in claims.items() if value}
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -146,11 +146,11 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
constraint=models.CheckConstraint(condition=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
|
||||
constraint=models.CheckConstraint(condition=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
|
||||
@@ -124,12 +124,12 @@ class Contact(BaseModel):
|
||||
unique_together = ("owner", "base")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(base__isnull=False, owner__isnull=True),
|
||||
condition=~models.Q(base__isnull=False, owner__isnull=True),
|
||||
name="base_owner_constraint",
|
||||
violation_error_message="A contact overriding a base contact must be owned.",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(base=models.F("id")),
|
||||
condition=~models.Q(base=models.F("id")),
|
||||
name="base_not_self",
|
||||
violation_error_message="A contact cannot be based on itself.",
|
||||
),
|
||||
|
||||
@@ -19,7 +19,8 @@ class AuthorizationServerClient:
|
||||
- Setting appropriate headers for secure communication as recommended by RFC drafts.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913
|
||||
# ruff: noqa: PLR0913 PLR017
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
@@ -8,6 +9,7 @@ from core import factories, models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
@@ -101,6 +103,59 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
assert user.name == f"{first_name:s} {last_name:s}"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email,
|
||||
the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub doesn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
@@ -169,3 +224,63 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_sub(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the sub but is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory(name="John Doe", is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": db_user.sub,
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(1),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user does not matches the sub but match the email and is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory(name="John Doe", is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "random",
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(SuspiciousOperation, match="User account is disabled"),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
@@ -21,6 +21,7 @@ def test_api_config_anonymous():
|
||||
assert response.json() == {
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
|
||||
"FEATURES": {"TEAMS": True},
|
||||
"RELEASE": "NA",
|
||||
}
|
||||
|
||||
|
||||
@@ -36,4 +37,5 @@ def test_api_config_authenticated():
|
||||
assert response.json() == {
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
|
||||
"FEATURES": {"TEAMS": True},
|
||||
"RELEASE": "NA",
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.urls import path
|
||||
|
||||
from .views import (
|
||||
DebugViewHtml,
|
||||
DebugViewNewMailboxHtml,
|
||||
DebugViewTxt,
|
||||
)
|
||||
|
||||
@@ -18,4 +19,9 @@ urlpatterns = [
|
||||
DebugViewTxt.as_view(),
|
||||
name="debug.mail.invitation_txt",
|
||||
),
|
||||
path(
|
||||
"__debug__/mail/new_mailbox_html",
|
||||
DebugViewNewMailboxHtml.as_view(),
|
||||
name="debug.mail.new_mailbox_html",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -25,3 +25,17 @@ class DebugViewTxt(DebugBaseView):
|
||||
"""Debug View for Text Email Layout"""
|
||||
|
||||
template_name = "mail/text/invitation.txt"
|
||||
|
||||
|
||||
class DebugViewNewMailboxHtml(DebugBaseView):
|
||||
"""Debug view for new mailbox email layout"""
|
||||
|
||||
template_name = "mail/html/new_mailbox.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["mailbox_data"] = {
|
||||
"email": "john.doe@example.com",
|
||||
"password": "6HGVAsjoog_v",
|
||||
}
|
||||
return context
|
||||
|
||||
@@ -4,4 +4,5 @@ NB_OBJECTS = {
|
||||
"users": 1000,
|
||||
"teams": 100,
|
||||
"max_users_per_team": 100,
|
||||
"domains": 20,
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ from uuid import uuid4
|
||||
from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.text import slugify
|
||||
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
from demo import defaults
|
||||
from mailbox_manager import models as mailbox_models
|
||||
from mailbox_manager.enums import MailDomainStatusChoices
|
||||
|
||||
fake = Faker()
|
||||
|
||||
@@ -152,6 +155,35 @@ def create_demo(stdout):
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating domains"):
|
||||
for _i in range(defaults.NB_OBJECTS["domains"]):
|
||||
name = fake.domain_name()
|
||||
slug = slugify(name)
|
||||
|
||||
queue.push(
|
||||
mailbox_models.MailDomain(
|
||||
name=name,
|
||||
# slug should be automatic but bulk_create doesn't use save
|
||||
slug=slug,
|
||||
status=random.choice(MailDomainStatusChoices.choices)[0],
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating accesses to domains"):
|
||||
domains_ids = list(
|
||||
mailbox_models.MailDomain.objects.values_list("id", flat=True)
|
||||
)
|
||||
for domain_id in domains_ids:
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domain_id,
|
||||
user_id=random.choice(users_ids),
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""A management command to create a demo database."""
|
||||
|
||||
@@ -10,12 +10,13 @@ import pytest
|
||||
from core import models
|
||||
|
||||
from demo import defaults
|
||||
from mailbox_manager import models as mailbox_models
|
||||
|
||||
TEST_NB_OBJECTS = {
|
||||
"users": 5,
|
||||
"teams": 3,
|
||||
"max_identities_per_user": 3,
|
||||
"max_users_per_team": 5,
|
||||
"domains": 2,
|
||||
}
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -27,9 +28,11 @@ def test_commands_create_demo():
|
||||
"""The create_demo management command should create objects as expected."""
|
||||
call_command("create_demo")
|
||||
|
||||
assert models.User.objects.count() == 5
|
||||
assert models.Team.objects.count() == 3
|
||||
assert models.TeamAccess.objects.count() >= 3
|
||||
assert models.User.objects.count() == TEST_NB_OBJECTS["users"]
|
||||
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
|
||||
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
|
||||
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
|
||||
assert mailbox_models.MailDomainAccess.objects.count() == TEST_NB_OBJECTS["domains"]
|
||||
|
||||
|
||||
def test_commands_createsuperuser():
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-03-21 19:26+0000\n"
|
||||
"POT-Creation-Date: 2024-10-15 10:52+0000\n"
|
||||
"PO-Revision-Date: 2024-01-03 23:15\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
@@ -17,212 +17,251 @@ msgstr ""
|
||||
"X-Crowdin-File: backend.pot\n"
|
||||
"X-Crowdin-File-ID: 2\n"
|
||||
|
||||
#: core/admin.py:70
|
||||
#: core/admin.py:45
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:72
|
||||
#: core/admin.py:47
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:84
|
||||
#: core/admin.py:59
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:81
|
||||
#: core/admin.py:97
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:82
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:114
|
||||
#: core/authentication/backends.py:90
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:38
|
||||
#: core/enums.py:23
|
||||
msgid "Failure"
|
||||
msgstr ""
|
||||
|
||||
#: core/enums.py:24 mailbox_manager/enums.py:20
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: core/enums.py:25
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:42
|
||||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:43 mailbox_manager/enums.py:13
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:40
|
||||
#: core/models.py:44 mailbox_manager/enums.py:14
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:56
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:57
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:63
|
||||
msgid "created at"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:64
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:65
|
||||
#: core/models.py:69
|
||||
msgid "updated at"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:66
|
||||
#: core/models.py:70
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:101
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:102
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:107
|
||||
msgid "contact information"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:108
|
||||
msgid "A JSON object containing the contact information"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:122
|
||||
msgid "contact"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:119
|
||||
#: core/models.py:123
|
||||
msgid "contacts"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160 core/models.py:263 core/models.py:483
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:173
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:189
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:192
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:195
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:207
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:208
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:248
|
||||
#: core/models.py:167
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:255
|
||||
#: core/models.py:173
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:257
|
||||
#: core/models.py:175
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:264
|
||||
#: core/models.py:181 core/models.py:489
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182 mailbox_manager/models.py:20
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:266
|
||||
msgid "main"
|
||||
#: core/models.py:194
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:268
|
||||
msgid "Designates whether the email is the main one."
|
||||
#: core/models.py:195
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:274
|
||||
msgid "identity"
|
||||
#: core/models.py:201
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:275
|
||||
msgid "identities"
|
||||
#: core/models.py:204
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:282
|
||||
msgid "This email address is already declared for this user."
|
||||
#: core/models.py:206
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:356
|
||||
#: core/models.py:209
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:211
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:214
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:217
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:229
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:230
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:306
|
||||
msgid "Team"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:357
|
||||
#: core/models.py:307
|
||||
msgid "Teams"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:417
|
||||
#: core/models.py:367
|
||||
msgid "Team/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:418
|
||||
#: core/models.py:368
|
||||
msgid "Team/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:423
|
||||
#: core/models.py:373
|
||||
msgid "This user is already in this team."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:500
|
||||
#: core/models.py:462
|
||||
msgid "url"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:463
|
||||
msgid "secret"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:472
|
||||
msgid "Team webhook"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:473
|
||||
msgid "Team webhooks"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:506
|
||||
msgid "Team invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:501
|
||||
#: core/models.py:507
|
||||
msgid "Team invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:526
|
||||
#: core/models.py:532
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:568 core/models.py:573
|
||||
#: core/models.py:574 core/models.py:580
|
||||
msgid "Invitation to join Desk!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
@@ -246,7 +285,7 @@ msgstr ""
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
"We are delighted to welcome you to our community on Equipes, your new "
|
||||
"We are delighted to welcome you to our community on Régie, your new "
|
||||
"companion to simplify the management of your groups efficiently, "
|
||||
"intuitively, and securely."
|
||||
msgstr ""
|
||||
@@ -260,7 +299,7 @@ msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:236
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "With Equipes, you will be able to:"
|
||||
msgid "With Régie, you will be able to:"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
@@ -294,13 +333,13 @@ msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:252
|
||||
#: core/templates/mail/text/invitation.txt:23
|
||||
msgid "Visit Equipes"
|
||||
msgid "Visit Régie"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:261
|
||||
#: core/templates/mail/text/invitation.txt:25
|
||||
msgid ""
|
||||
"We are confident that Equipes will help you increase efficiency and "
|
||||
"We are confident that Régie will help you increase efficiency and "
|
||||
"productivity while strengthening the bond among members."
|
||||
msgstr ""
|
||||
|
||||
@@ -320,19 +359,126 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:278
|
||||
#: core/templates/mail/html/new_mailbox.html:272
|
||||
#: core/templates/mail/text/invitation.txt:29
|
||||
#: core/templates/mail/text/new_mailbox.txt:15
|
||||
msgid "Sincerely,"
|
||||
msgstr ""
|
||||
msgstr "Cordialement,"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:279
|
||||
#: core/templates/mail/text/invitation.txt:31
|
||||
msgid "The La Suite Numérique Team"
|
||||
msgstr "L'équipe de La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:159
|
||||
#: core/templates/mail/text/new_mailbox.txt:3
|
||||
msgid "La Messagerie"
|
||||
msgstr "La Messagerie"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:188
|
||||
#: core/templates/mail/text/new_mailbox.txt:5
|
||||
msgid "Welcome to La Messagerie"
|
||||
msgstr "Bienvenue dans La Messagerie"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:193
|
||||
#: core/templates/mail/text/new_mailbox.txt:6
|
||||
msgid "La Messagerie is the email solution of La Suite."
|
||||
msgstr "La Messagerie est la solution de mail de La Suite."
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:199
|
||||
#: core/templates/mail/text/new_mailbox.txt:7
|
||||
msgid "Your mailbox has been created."
|
||||
msgstr "Votre boîte mail a été créée."
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:204
|
||||
#: core/templates/mail/text/new_mailbox.txt:8
|
||||
msgid "Please find below your login info: "
|
||||
msgstr "Voici vos identifiants de connexion :"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:228
|
||||
#: core/templates/mail/text/new_mailbox.txt:10
|
||||
msgid "Email address: "
|
||||
msgstr "Adresse email : "
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:233
|
||||
#: core/templates/mail/text/new_mailbox.txt:11
|
||||
msgid "Temporary password (to be modify on first login): "
|
||||
msgstr "Mot de passe temporaire (à modifier à la première connexion) : "
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:261
|
||||
#: core/templates/mail/text/new_mailbox.txt:13
|
||||
msgid "Go to La Messagerie"
|
||||
msgstr "Accéder à La Messagerie"
|
||||
|
||||
#: core/templates/mail/html/new_mailbox.html:273
|
||||
#: core/templates/mail/text/new_mailbox.txt:17
|
||||
msgid "La Suite Team"
|
||||
msgstr "L'équipe de La Suite"
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: people/settings.py:133
|
||||
#: mailbox_manager/enums.py:12
|
||||
msgid "Viewer"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/enums.py:21
|
||||
msgid "Enabled"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/enums.py:22
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/enums.py:23
|
||||
msgid "Disabled"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:31
|
||||
msgid "Mail domain"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:32
|
||||
msgid "Mail domains"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:98
|
||||
msgid "User/mail domain relation"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:99
|
||||
msgid "User/mail domain relations"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:171
|
||||
msgid "local_part"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:185
|
||||
msgid "secondary email address"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:190
|
||||
msgid "Mailbox"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:191
|
||||
msgid "Mailboxes"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/utils/dimail.py:137
|
||||
msgid "Your new mailbox information"
|
||||
msgstr "Informations concernant votre nouvelle boîte mail"
|
||||
|
||||
#: people/settings.py:134
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: people/settings.py:134
|
||||
#: people/settings.py:135
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#~ msgid "Regards,"
|
||||
#~ msgstr "Cordialement,"
|
||||
|
||||
@@ -6,6 +6,14 @@ from django.utils.translation import gettext_lazy as _
|
||||
from mailbox_manager import models
|
||||
|
||||
|
||||
class UserMailDomainAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for mail domain accesses."""
|
||||
|
||||
extra = 0
|
||||
model = models.MailDomainAccess
|
||||
readonly_fields = ("created_at", "updated_at", "domain", "user")
|
||||
|
||||
|
||||
@admin.register(models.MailDomain)
|
||||
class MailDomainAdmin(admin.ModelAdmin):
|
||||
"""Mail domain admin interface declaration."""
|
||||
@@ -19,6 +27,7 @@ class MailDomainAdmin(admin.ModelAdmin):
|
||||
)
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ["created_at", "slug"]
|
||||
inlines = (UserMailDomainAccessInline,)
|
||||
|
||||
|
||||
@admin.register(models.MailDomainAccess)
|
||||
|
||||
@@ -21,3 +21,12 @@ class MailBoxPermission(core_permissions.IsAuthenticated):
|
||||
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
|
||||
abilities = domain.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class to manage mailboxes for a mail domain"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Client serializers for People's mailbox manager app."""
|
||||
|
||||
from rest_framework import serializers
|
||||
import json
|
||||
|
||||
from mailbox_manager import models
|
||||
from rest_framework import exceptions, serializers
|
||||
|
||||
from core.api.serializers import UserSerializer
|
||||
from core.models import User
|
||||
|
||||
from mailbox_manager import enums, models
|
||||
from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
|
||||
|
||||
class MailboxSerializer(serializers.ModelSerializer):
|
||||
@@ -14,6 +20,29 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
# everything is actually read-only as we do not allow update for now
|
||||
read_only_fields = ["id"]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Override create function to fire a request on mailbox creation.
|
||||
"""
|
||||
# send new mailbox request to dimail
|
||||
client = DimailAPIClient()
|
||||
response = client.send_mailbox_request(
|
||||
validated_data, self.context["request"].user.sub
|
||||
)
|
||||
|
||||
# fix format to have actual json, and remove uuid
|
||||
mailbox_data = json.loads(response.content.decode("utf-8").replace("'", '"'))
|
||||
del mailbox_data["uuid"]
|
||||
|
||||
# actually save mailbox on our database
|
||||
instance = models.Mailbox.objects.create(**validated_data)
|
||||
|
||||
# send confirmation email
|
||||
client.send_new_mailbox_notification(
|
||||
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
class MailDomainSerializer(serializers.ModelSerializer):
|
||||
"""Serialize mail domain."""
|
||||
@@ -50,7 +79,89 @@ class MailDomainSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize mail domain accesses."""
|
||||
"""Serialize mail domain access."""
|
||||
|
||||
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
|
||||
can_set_role_to = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.MailDomainAccess
|
||||
fields = ["id", "user", "role", "can_set_role_to"]
|
||||
read_only_fields = ["id", "can_set_role_to"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "user" field is readonly but only on update."""
|
||||
validated_data.pop("user", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def get_can_set_role_to(self, access):
|
||||
"""Return roles available to set for the authenticated user"""
|
||||
return access.get_can_set_role_to(self.context.get("request").user)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Check access rights specific to writing (update/create)
|
||||
"""
|
||||
|
||||
request = self.context.get("request")
|
||||
authenticated_user = getattr(request, "user", None)
|
||||
role = attrs.get("role")
|
||||
|
||||
# Update
|
||||
if self.instance:
|
||||
can_set_role_to = self.instance.get_can_set_role_to(authenticated_user)
|
||||
|
||||
if role and role not in can_set_role_to:
|
||||
message = (
|
||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
||||
if can_set_role_to
|
||||
else "You are not allowed to modify role for this user."
|
||||
)
|
||||
raise exceptions.PermissionDenied(message)
|
||||
# Create
|
||||
else:
|
||||
# A domain slug has to be set to create a new access
|
||||
try:
|
||||
domain_slug = self.context["domain_slug"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a domain slug in kwargs to create a new domain access."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
access = authenticated_user.mail_domain_accesses.get(
|
||||
domain__slug=domain_slug
|
||||
)
|
||||
except models.MailDomainAccess.DoesNotExist as exc:
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this domain."
|
||||
) from exc
|
||||
|
||||
# Authenticated user must be owner or admin of current domain to set new roles
|
||||
if access.role not in [
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
]:
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this domain."
|
||||
)
|
||||
# only an owner can set an owner role to another user
|
||||
if (
|
||||
role == enums.MailDomainRoleChoices.OWNER
|
||||
and access.role != enums.MailDomainRoleChoices.OWNER
|
||||
):
|
||||
raise exceptions.PermissionDenied(
|
||||
"Only owners of a domain can assign other users as owners."
|
||||
)
|
||||
attrs["user"] = User.objects.get(pk=self.initial_data["user"])
|
||||
attrs["domain"] = models.MailDomain.objects.get(
|
||||
slug=self.context["domain_slug"]
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
|
||||
"""Serialize mail domain access for list and retrieve actions."""
|
||||
|
||||
class Meta:
|
||||
model = models.MailDomainAccess
|
||||
@@ -58,7 +169,11 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"can_set_role_to",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"can_set_role_to",
|
||||
]
|
||||
read_only_fields = ["id"]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""API endpoints"""
|
||||
|
||||
from rest_framework import filters, mixins, viewsets
|
||||
from rest_framework import permissions as drf_permissions
|
||||
from django.db.models import Subquery
|
||||
|
||||
from rest_framework import exceptions, filters, mixins, viewsets
|
||||
|
||||
from core import models as core_models
|
||||
|
||||
from mailbox_manager import models
|
||||
from mailbox_manager import enums, models
|
||||
from mailbox_manager.api import permissions, serializers
|
||||
|
||||
|
||||
@@ -54,19 +55,125 @@ class MailDomainViewSet(
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
class MailDomainAccessViewSet(
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
"""
|
||||
MailDomainAccess viewset.
|
||||
API ViewSet for all interactions with mail domain accesses.
|
||||
|
||||
GET /api/v1.0/mail-domains/<domain_slug>/accesses/:<domain_access_id>
|
||||
Return list of all domain accesses related to the logged-in user and one
|
||||
domain access if an id is provided.
|
||||
|
||||
POST /api/v1.0/mail-domains/<domain_slug>/accesses/ with expected data:
|
||||
- user: str
|
||||
- role: str [owner|admin|viewer]
|
||||
Return newly created mail domain access
|
||||
|
||||
PUT /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
|
||||
- role: str [owner|admin|viewer]
|
||||
Return updated domain access
|
||||
|
||||
PATCH /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
|
||||
- role: str [owner|admin|viewer]
|
||||
Return partially updated domain access
|
||||
|
||||
DELETE /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/
|
||||
Delete targeted domain access
|
||||
"""
|
||||
|
||||
permission_classes = [drf_permissions.IsAuthenticated]
|
||||
permission_classes = [permissions.MailDomainAccessRolePermission]
|
||||
serializer_class = serializers.MailDomainAccessSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at", "user", "domain", "role"]
|
||||
ordering_fields = ["role", "user__email", "user__name"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.MailDomainAccess.objects.all()
|
||||
queryset = (
|
||||
models.MailDomainAccess.objects.all()
|
||||
.select_related("user")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
list_serializer_class = serializers.MailDomainAccessReadOnlySerializer
|
||||
detail_serializer_class = serializers.MailDomainAccessSerializer
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in {"list", "retrieve"}:
|
||||
return self.list_serializer_class
|
||||
return self.detail_serializer_class
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["domain_slug"] = self.kwargs["domain_slug"]
|
||||
context["authenticated_user"] = self.request.user
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
|
||||
|
||||
if self.action in {"list", "retrieve"}:
|
||||
# Determine which role the logged-in user has in the domain
|
||||
user_role_query = models.MailDomainAccess.objects.filter(
|
||||
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
|
||||
).values("role")[:1]
|
||||
|
||||
queryset = (
|
||||
# The logged-in user should be part of a domain to see its accesses
|
||||
queryset.filter(
|
||||
domain__accesses__user=self.request.user,
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each domain access
|
||||
.annotate(
|
||||
user_role=Subquery(user_role_query),
|
||||
)
|
||||
.select_related("user")
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Check that we don't change the role if it leads to losing the last owner."""
|
||||
instance = serializer.instance
|
||||
|
||||
# Check if the role is being updated and the new role is not "owner"
|
||||
if (
|
||||
"role" in self.request.data
|
||||
and self.request.data["role"] != enums.MailDomainRoleChoices.OWNER
|
||||
):
|
||||
domain = instance.domain
|
||||
# Check if the access being updated is the last owner access for the domain
|
||||
if (
|
||||
instance.role == enums.MailDomainRoleChoices.OWNER
|
||||
and domain.accesses.filter(
|
||||
role=enums.MailDomainRoleChoices.OWNER
|
||||
).count()
|
||||
== 1
|
||||
):
|
||||
message = "Cannot change the role to a non-owner role for the last owner access."
|
||||
raise exceptions.PermissionDenied({"role": message})
|
||||
serializer.save()
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Forbid deleting the last owner access"""
|
||||
instance = self.get_object()
|
||||
domain = instance.domain
|
||||
|
||||
# Check if the access being deleted is the last owner access for the domain
|
||||
if (
|
||||
instance.role == enums.MailDomainRoleChoices.OWNER
|
||||
and domain.accesses.filter(role=enums.MailDomainRoleChoices.OWNER).count()
|
||||
== 1
|
||||
):
|
||||
message = "Cannot delete the last owner access for the domain."
|
||||
raise exceptions.PermissionDenied({"detail": message})
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class MailBoxViewSet(
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
Mailbox manager application factories
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from django.utils.text import slugify
|
||||
|
||||
import factory.fuzzy
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories as core_factories
|
||||
from core import models as core_models
|
||||
@@ -31,7 +27,6 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
name = factory.Faker("domain_name")
|
||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||
secret = factory.Faker("password")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
@@ -80,37 +75,3 @@ class MailboxFactory(factory.django.DjangoModelFactory):
|
||||
)
|
||||
domain = factory.SubFactory(MailDomainEnabledFactory)
|
||||
secondary_email = factory.Faker("email")
|
||||
|
||||
@classmethod
|
||||
def _create(cls, model_class, *args, use_mock=True, **kwargs):
|
||||
domain = kwargs["domain"]
|
||||
if use_mock and isinstance(domain, models.MailDomain):
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "domain_owner_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(
|
||||
rf".*/domains/{domain.name}/mailboxes/{kwargs['local_part']}"
|
||||
),
|
||||
body=str(
|
||||
{
|
||||
"email": f"{kwargs['local_part']}@{domain.name}",
|
||||
"password": "newpass",
|
||||
"uuid": "uuid",
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
result = super()._create(model_class, *args, **kwargs)
|
||||
else:
|
||||
result = super()._create(model_class, *args, **kwargs)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Management command creating a dimail-api container, for test purposes."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
import requests
|
||||
from rest_framework import status
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
DIMAIL_URL = "http://host.docker.internal:8001"
|
||||
admin = {"username": "admin", "password": "admin"}
|
||||
regie = {"username": "la_regie", "password": "password"}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command populate local dimail database, to ease dev
|
||||
"""
|
||||
|
||||
help = "Populate local dimail database, for dev purposes."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Handling of the management command."""
|
||||
if not settings.DEBUG:
|
||||
raise CommandError(
|
||||
("This command is meant to run in local dev environment.")
|
||||
)
|
||||
|
||||
# Create a first superuser for dimail-api container. User creation is usually
|
||||
# protected behind admin rights but dimail allows to create a first user
|
||||
# when database is empty
|
||||
self.create_user(
|
||||
auth=(None, None),
|
||||
name=admin["username"],
|
||||
password=admin["password"],
|
||||
perms=[],
|
||||
)
|
||||
|
||||
# Create Regie user, auth for all remaining requests
|
||||
# and your own dev
|
||||
self.create_user(
|
||||
auth=(admin["username"], admin["password"]),
|
||||
name=regie["username"],
|
||||
password=regie["password"],
|
||||
perms=["new_domain", "create_users", "manage_users"],
|
||||
)
|
||||
|
||||
# we create a dimail user for keycloak+regie user John Doe
|
||||
# This way, la Régie will be able to make request in the name of
|
||||
# this user
|
||||
people_base_user = User.objects.get(name="John Doe")
|
||||
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
|
||||
|
||||
# we create a domain and add John Doe to it
|
||||
domain_name = "test.domain.com"
|
||||
self.create_domain(domain_name)
|
||||
self.create_allows(people_base_user.sub, domain_name)
|
||||
|
||||
self.stdout.write("DONE", ending="\n")
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
name,
|
||||
password,
|
||||
perms=None,
|
||||
auth=(regie["username"], regie["password"]),
|
||||
):
|
||||
"""
|
||||
Send a request to create a new user.
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
url=f"{DIMAIL_URL}/users/",
|
||||
json={
|
||||
"name": name,
|
||||
"password": password,
|
||||
"is_admin": name == admin["username"],
|
||||
"perms": perms or [],
|
||||
},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
self.stdout.write(self.style.SUCCESS(f"Creating user {name}......... OK"))
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Creating user {name} ......... failed: {response.json()['detail']}"
|
||||
)
|
||||
)
|
||||
|
||||
def create_domain(self, name, auth=(regie["username"], regie["password"])):
|
||||
"""
|
||||
Send a request to create a new domain.
|
||||
"""
|
||||
response = requests.post(
|
||||
url=f"{DIMAIL_URL}/domains/",
|
||||
json={
|
||||
"name": name,
|
||||
"context_name": "context",
|
||||
"features": ["webmail", "mailbox", "alias"],
|
||||
},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Creating domain '{name}' ........ OK")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Creating domain '{name}' ........ failed: {response.json()['detail']}"
|
||||
)
|
||||
)
|
||||
|
||||
def create_allows(self, user, domain, auth=(regie["username"], regie["password"])):
|
||||
"""
|
||||
Send a request to create a new allows between user and domain.
|
||||
"""
|
||||
response = requests.post(
|
||||
url=f"{DIMAIL_URL}/allows/",
|
||||
json={
|
||||
"domain": domain,
|
||||
"user": user,
|
||||
},
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Creating permissions for {user} on {domain} ........ OK"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Creating permissions for {user} on {domain}\
|
||||
........ failed: {response.json()['detail']}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1 on 2024-08-30 12:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0012_alter_mailbox_local_part'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='maildomain',
|
||||
name='secret',
|
||||
),
|
||||
]
|
||||
@@ -4,14 +4,13 @@ Declare and configure the models for the People additional application : mailbox
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import exceptions, validators
|
||||
from django.db import models, transaction
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import BaseModel
|
||||
|
||||
from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
|
||||
from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
|
||||
|
||||
class MailDomain(BaseModel):
|
||||
@@ -26,7 +25,6 @@ class MailDomain(BaseModel):
|
||||
default=MailDomainStatusChoices.PENDING,
|
||||
choices=MailDomainStatusChoices.choices,
|
||||
)
|
||||
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_mail_domain"
|
||||
@@ -49,7 +47,6 @@ class MailDomain(BaseModel):
|
||||
"""
|
||||
Compute and return abilities for a given user on the domain.
|
||||
"""
|
||||
is_owner_or_admin = False
|
||||
role = None
|
||||
|
||||
if user.is_authenticated:
|
||||
@@ -105,6 +102,65 @@ class MailDomainAccess(BaseModel):
|
||||
def __str__(self):
|
||||
return f"Access of user {self.user} on domain {self.domain}."
|
||||
|
||||
def get_can_set_role_to(self, user):
|
||||
"""Return roles available to set"""
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
roles = list(MailDomainRoleChoices)
|
||||
authenticated_user_role = None
|
||||
|
||||
# get role of authenticated user
|
||||
if hasattr(self, "user_role"):
|
||||
authenticated_user_role = self.user_role
|
||||
else:
|
||||
try:
|
||||
authenticated_user_role = user.mail_domain_accesses.get(
|
||||
domain=self.domain
|
||||
).role
|
||||
except (MailDomainAccess.DoesNotExist, IndexError):
|
||||
return []
|
||||
|
||||
# only an owner can set an owner role
|
||||
if authenticated_user_role != MailDomainRoleChoices.OWNER:
|
||||
roles.remove(MailDomainRoleChoices.OWNER)
|
||||
|
||||
# if the user authenticated is a viewer, they can't modify role
|
||||
# and only an owner can change role of an owner
|
||||
if authenticated_user_role == MailDomainRoleChoices.VIEWER or (
|
||||
authenticated_user_role != MailDomainRoleChoices.OWNER
|
||||
and self.role == MailDomainRoleChoices.OWNER
|
||||
):
|
||||
return []
|
||||
# we only want to return other roles available to change,
|
||||
# so we remove the current role of current access.
|
||||
roles.remove(self.role)
|
||||
return sorted(roles)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the domain access.
|
||||
"""
|
||||
role = None
|
||||
|
||||
if user.is_authenticated:
|
||||
try:
|
||||
role = user.mail_domain_accesses.filter(domain=self.domain).get().role
|
||||
except (MailDomainAccess.DoesNotExist, IndexError):
|
||||
role = None
|
||||
|
||||
is_owner_or_admin = role in [
|
||||
MailDomainRoleChoices.OWNER,
|
||||
MailDomainRoleChoices.ADMIN,
|
||||
]
|
||||
|
||||
return {
|
||||
"get": bool(role),
|
||||
"patch": is_owner_or_admin,
|
||||
"put": is_owner_or_admin,
|
||||
"post": is_owner_or_admin,
|
||||
"delete": is_owner_or_admin,
|
||||
}
|
||||
|
||||
|
||||
class Mailbox(BaseModel):
|
||||
"""Mailboxes for users from mail domain."""
|
||||
@@ -139,29 +195,29 @@ class Mailbox(BaseModel):
|
||||
return f"{self.local_part!s}@{self.domain.name:s}"
|
||||
|
||||
def clean(self):
|
||||
"""Mailboxes can be created only on enabled domains, with a set secret."""
|
||||
"""
|
||||
Mailboxes can only be created on enabled domains.
|
||||
Also, mail-provisioning API credentials must be set for dimail to allow auth.
|
||||
"""
|
||||
if self.domain.status != MailDomainStatusChoices.ENABLED:
|
||||
raise exceptions.ValidationError(
|
||||
"You can create mailbox only for a domain enabled"
|
||||
)
|
||||
|
||||
if not self.domain.secret:
|
||||
# Won't be able to query user token if MAIL_PROVISIONING_API_CREDENTIALS are not set
|
||||
if not settings.MAIL_PROVISIONING_API_CREDENTIALS:
|
||||
raise exceptions.ValidationError(
|
||||
"Please configure your domain's secret before creating any mailbox."
|
||||
"Please configure MAIL_PROVISIONING_API_CREDENTIALS before creating any mailbox."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save function to fire a request on mailbox creation.
|
||||
Modification is forbidden for now.
|
||||
"""
|
||||
self.full_clean()
|
||||
|
||||
if self._state.adding:
|
||||
with transaction.atomic():
|
||||
client = DimailAPIClient()
|
||||
client.send_mailbox_request(self)
|
||||
return super().save(*args, **kwargs)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
# Update is not implemented for now
|
||||
raise NotImplementedError()
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Test for mail domain accesses API endpoints in People's core app : create
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create mail domain accesses."""
|
||||
user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.MailDomainAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create domain accesses for a domain to
|
||||
which they are not related.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
other_user = core_factories.UserFactory()
|
||||
domain = factories.MailDomainFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this domain."
|
||||
}
|
||||
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_create_authenticated_viewer():
|
||||
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
|
||||
)
|
||||
other_user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this domain."
|
||||
}
|
||||
|
||||
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_create_authenticated_administrator():
|
||||
"""
|
||||
Administrators of a domain should be able to create mail domain accesses
|
||||
except for the "owner" role.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
|
||||
)
|
||||
other_user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": enums.MailDomainRoleChoices.OWNER,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a domain can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
for role in [enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.VIEWER]:
|
||||
other_user = core_factories.UserFactory()
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
new_mail_domain_access = models.MailDomainAccess.objects.filter(
|
||||
user=other_user
|
||||
).last()
|
||||
|
||||
assert response.json()["id"] == str(new_mail_domain_access.id)
|
||||
assert response.json()["role"] == role
|
||||
assert models.MailDomainAccess.objects.filter(domain=mail_domain).count() == 3
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_create_authenticated_owner():
|
||||
"""
|
||||
Owners of a mail domain should be able to create mail domain accesses whatever the role.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.OWNER)]
|
||||
)
|
||||
other_user = core_factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in enums.MailDomainRoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert models.MailDomainAccess.objects.filter(user=other_user).count() == 1
|
||||
new_mail_domain_access = models.MailDomainAccess.objects.filter(
|
||||
user=other_user
|
||||
).get()
|
||||
assert response.json()["id"] == str(new_mail_domain_access.id)
|
||||
assert response.json()["role"] == role
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Test for mail_domain accesses API endpoints in People's core app : delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a mail domain access."""
|
||||
access = factories.MailDomainAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a mail domain access for a
|
||||
mail domain to which they are not related.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
access = factories.MailDomainAccessFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_viewer():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a mail domain access for a
|
||||
mail domain in which they are a simple viewer.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
|
||||
)
|
||||
access = factories.MailDomainAccessFactory(domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert models.MailDomainAccess.objects.count() == 2
|
||||
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_administrators():
|
||||
"""
|
||||
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
|
||||
)
|
||||
for role in [enums.MailDomainRoleChoices.VIEWER, enums.MailDomainRoleChoices.ADMIN]:
|
||||
access = factories.MailDomainAccessFactory(domain=mail_domain, role=role)
|
||||
|
||||
assert models.MailDomainAccess.objects.count() == 2
|
||||
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_owners():
|
||||
"""
|
||||
An owner should be able to delete the mail domain access of another user including
|
||||
a owner access.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.OWNER)]
|
||||
)
|
||||
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
|
||||
access = factories.MailDomainAccessFactory(domain=mail_domain, role=role)
|
||||
|
||||
assert models.MailDomainAccess.objects.count() == 2
|
||||
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_owners_last_owner():
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a mail domain
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
access = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain,
|
||||
user=authenticated_user,
|
||||
role=enums.MailDomainRoleChoices.OWNER,
|
||||
)
|
||||
factories.MailDomainAccessFactory.create_batch(9)
|
||||
assert models.MailDomainAccess.objects.count() == 10
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert models.MailDomainAccess.objects.count() == 10
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Test for mail_domain accesses API endpoints in People's core app : list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list mail_domain accesses."""
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
factories.MailDomainAccessFactory.create_batch(2, domain=mail_domain)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
|
||||
to which they are not related.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
factories.MailDomainAccessFactory.create_batch(3, domain=mail_domain)
|
||||
|
||||
# Accesses for other mail_domains to which the user is related should not be listed either
|
||||
other_access = factories.MailDomainAccessFactory(user=user)
|
||||
factories.MailDomainAccessFactory(domain=other_access.domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_list_for_authenticated_user_related_to_domain():
|
||||
"""
|
||||
Authenticated users should be able to list mail_domain accesses for a mail_domain
|
||||
to which they are related, with a given role.
|
||||
"""
|
||||
viewer, administrator, owner = core_factories.UserFactory.create_batch(3)
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
|
||||
owner_access = factories.MailDomainAccessFactory.create(
|
||||
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
admin_access = factories.MailDomainAccessFactory.create(
|
||||
domain=mail_domain, user=administrator, role=enums.MailDomainRoleChoices.ADMIN
|
||||
)
|
||||
viewer_access = models.MailDomainAccess.objects.create(
|
||||
domain=mail_domain, user=viewer, role=enums.MailDomainRoleChoices.VIEWER
|
||||
)
|
||||
|
||||
admin_expected_data = {
|
||||
"id": str(admin_access.id),
|
||||
"user": {
|
||||
"id": str(administrator.id),
|
||||
"email": str(administrator.email),
|
||||
"name": str(administrator.name),
|
||||
},
|
||||
"role": str(admin_access.role),
|
||||
}
|
||||
viewer_expected_data = {
|
||||
"id": str(viewer_access.id),
|
||||
"user": {
|
||||
"id": str(viewer.id),
|
||||
"email": str(viewer.email),
|
||||
"name": str(viewer.name),
|
||||
},
|
||||
"role": str(viewer_access.role),
|
||||
}
|
||||
owner_expected_data = {
|
||||
"id": str(owner_access.id),
|
||||
"user": {
|
||||
"id": str(owner.id),
|
||||
"email": str(owner.email),
|
||||
"name": str(owner.name),
|
||||
},
|
||||
"role": str(owner_access.role),
|
||||
}
|
||||
|
||||
# Grant other mail_domain accesses to the user, they should not be listed either
|
||||
other_access = factories.MailDomainAccessFactory(user=viewer)
|
||||
factories.MailDomainAccessFactory(domain=other_access.domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(viewer)
|
||||
# viewer can see accesses but no action is available
|
||||
admin_expected_data["can_set_role_to"] = []
|
||||
viewer_expected_data["can_set_role_to"] = []
|
||||
owner_expected_data["can_set_role_to"] = []
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 3
|
||||
expected = sorted(
|
||||
[admin_expected_data, viewer_expected_data, owner_expected_data],
|
||||
key=lambda x: x["role"],
|
||||
)
|
||||
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
|
||||
|
||||
client.force_login(administrator)
|
||||
# administrator can see and give new role but not an OWNER role
|
||||
admin_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.VIEWER]
|
||||
viewer_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.ADMIN]
|
||||
owner_expected_data["can_set_role_to"] = []
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 3
|
||||
expected = sorted(
|
||||
[admin_expected_data, viewer_expected_data, owner_expected_data],
|
||||
key=lambda x: x["role"],
|
||||
)
|
||||
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
|
||||
|
||||
client.force_login(owner)
|
||||
# owner can do everything
|
||||
admin_expected_data["can_set_role_to"] = [
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.VIEWER,
|
||||
]
|
||||
viewer_expected_data["can_set_role_to"] = [
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
]
|
||||
owner_expected_data["can_set_role_to"] = [
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.VIEWER,
|
||||
]
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 3
|
||||
|
||||
expected = sorted(
|
||||
[admin_expected_data, viewer_expected_data, owner_expected_data],
|
||||
key=lambda x: x["role"],
|
||||
)
|
||||
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
The number of queries should not depend on the amount of fetched accesses.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
models.MailDomainAccess.objects.create(domain=mail_domain, user=user) # random role
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
# Only 3 queries are needed to efficiently fetch mail_domain accesses,
|
||||
# related users :
|
||||
# - query retrieving logged-in user for user_role annotation
|
||||
# - count from pagination
|
||||
# - distinct from viewset
|
||||
with django_assert_num_queries(3):
|
||||
client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
|
||||
# create 20 new mail_domain accesses
|
||||
for _ in range(20):
|
||||
factories.MailDomainAccessFactory(domain=mail_domain)
|
||||
|
||||
# num queries should still be the same
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_list_authenticated_ordering():
|
||||
"""MailDomain accesses can be ordered by "role"."""
|
||||
|
||||
user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
models.MailDomainAccess.objects.create(domain=mail_domain, user=user)
|
||||
|
||||
# create 20 new mail_domain accesses
|
||||
for _ in range(20):
|
||||
factories.MailDomainAccessFactory(domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=role",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [access["role"] for access in response.json()["results"]]
|
||||
assert sorted(results) == results
|
||||
|
||||
# check results when we change ordering
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=-role",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [access["role"] for access in response.json()["results"]]
|
||||
assert sorted(results, reverse=True) == results
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ordering_field", ["email", "name"])
|
||||
def test_api_mail_domain__accesses_list_authenticated_ordering_user(ordering_field):
|
||||
"""Mail domain accesses can be ordered by user's fields."""
|
||||
|
||||
user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
models.MailDomainAccess.objects.create(domain=mail_domain, user=user)
|
||||
|
||||
for _ in range(20):
|
||||
factories.MailDomainAccessFactory(domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=user__{ordering_field}",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
def normalize(x):
|
||||
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
|
||||
return x.casefold().replace(" ", "")
|
||||
|
||||
results = [access["user"][ordering_field] for access in response.json()["results"]]
|
||||
assert sorted(results, key=normalize) == results
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Test for mail_domain accesses API endpoints in People's core app : retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a mail_domain access.
|
||||
"""
|
||||
access = factories.MailDomainAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a mail_domain access for
|
||||
a mail_domain to which they are not related.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
access = factories.MailDomainAccessFactory(domain=factories.MailDomainFactory())
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No MailDomainAccess matches the given query."}
|
||||
|
||||
# Accesses related to another mail_domain should be excluded even if the user is related to it
|
||||
for other_access in [
|
||||
factories.MailDomainAccessFactory(),
|
||||
factories.MailDomainAccessFactory(user=user),
|
||||
]:
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{other_access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"detail": "No MailDomainAccess matches the given query."
|
||||
}
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_retrieve_authenticated_related():
|
||||
"""
|
||||
A user who is related to a mail_domain should be allowed to retrieve the
|
||||
associated mail_domain user accesses.
|
||||
"""
|
||||
owner = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
access = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(owner)
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
results = {
|
||||
"id": str(access.id),
|
||||
"user": {
|
||||
"id": str(access.user.id),
|
||||
"email": str(owner.email),
|
||||
"name": str(owner.name),
|
||||
},
|
||||
"role": str(access.role),
|
||||
"can_set_role_to": [
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.VIEWER,
|
||||
],
|
||||
}
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == results
|
||||
|
||||
admin = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
|
||||
).user
|
||||
client.force_login(admin)
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
# admin can't change role of an owner
|
||||
results["can_set_role_to"] = []
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == results
|
||||
|
||||
viewer = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
|
||||
).user
|
||||
client.force_login(viewer)
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
# viewer can't change anyone's role
|
||||
results["can_set_role_to"] = []
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == results
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Test for mail_domain accesses API endpoints in People's core app : update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_anonymous():
|
||||
"""An anonymous users should not be allowed to update a mail domain access."""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
|
||||
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
access.refresh_from_db()
|
||||
assert access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
An authenticated user should not be allowed to update a mail domain access
|
||||
for a mail_domain to which they are not related.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
{"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
access.refresh_from_db()
|
||||
assert access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_authenticated_viewer():
|
||||
"""A viewer of a mail domain should not be allowed to update accesses."""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
|
||||
)
|
||||
access = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain,
|
||||
role=enums.MailDomainRoleChoices.VIEWER,
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
{"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
access.refresh_from_db()
|
||||
assert access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_authenticated_viewer_themself():
|
||||
"""A viewer of a mail domain should not be allowed to update its accesses."""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
access = factories.MailDomainAccessFactory(
|
||||
user=authenticated_user,
|
||||
role=enums.MailDomainRoleChoices.VIEWER,
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
{"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
access.refresh_from_db()
|
||||
assert access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_administrator_except_owner():
|
||||
"""
|
||||
An administrator of a mail domain should be allowed to update a user
|
||||
access for this mail domain, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
|
||||
)
|
||||
admin_access = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
|
||||
)
|
||||
viewer_access = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{admin_access.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.OWNER},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
admin_access.refresh_from_db()
|
||||
assert admin_access.role == enums.MailDomainRoleChoices.ADMIN
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{admin_access.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.VIEWER},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
admin_access.refresh_from_db()
|
||||
assert admin_access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{viewer_access.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.OWNER},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
viewer_access.refresh_from_db()
|
||||
assert viewer_access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{viewer_access.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
viewer_access.refresh_from_db()
|
||||
assert viewer_access.role == enums.MailDomainRoleChoices.ADMIN
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_administrator_from_owner():
|
||||
"""
|
||||
An administrator for a mail domain, should not be allowed to update
|
||||
the user access of an "owner" for this mail domain.
|
||||
"""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
|
||||
)
|
||||
owner = core_factories.UserFactory()
|
||||
owner_access = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{owner_access.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
owner_access.refresh_from_db()
|
||||
assert owner_access.role == enums.MailDomainRoleChoices.OWNER
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_owner():
|
||||
"""
|
||||
An owner of a mail domain should be allowed to update
|
||||
a user access for this domain.
|
||||
"""
|
||||
owner_authenticated = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(owner_authenticated, enums.MailDomainRoleChoices.OWNER)]
|
||||
)
|
||||
user_access1 = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
|
||||
)
|
||||
user_access2 = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(owner_authenticated)
|
||||
# turn admin in viewer
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access1.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.VIEWER},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
user_access1.refresh_from_db()
|
||||
assert user_access1.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
# turn viewer in owner
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access1.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.OWNER},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
user_access1.refresh_from_db()
|
||||
assert user_access1.role == enums.MailDomainRoleChoices.OWNER
|
||||
|
||||
# turn viewer in admin
|
||||
response = client.put(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access2.id!s}/",
|
||||
data={"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
user_access2.refresh_from_db()
|
||||
assert user_access2.role == enums.MailDomainRoleChoices.ADMIN
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_owner_for_owners():
|
||||
"""
|
||||
An owner of a mail domain should be allowed to update
|
||||
an existing owner access for this mail domain.
|
||||
"""
|
||||
owner_authenticated = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
users=[(owner_authenticated, enums.MailDomainRoleChoices.OWNER)]
|
||||
)
|
||||
other_owner_access = factories.MailDomainAccessFactory(
|
||||
domain=mail_domain, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(owner_authenticated)
|
||||
for new_role in [
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.VIEWER,
|
||||
]:
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{other_owner_access.id!s}/",
|
||||
data={"role": new_role},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
other_owner_access.refresh_from_db()
|
||||
assert other_owner_access.role == new_role
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_update_owner_self():
|
||||
"""
|
||||
An owner of a mail domain should be allowed to update
|
||||
their own user access provided there are other owners in the mail domain.
|
||||
"""
|
||||
owner_authenticated = core_factories.UserFactory()
|
||||
access = factories.MailDomainAccessFactory(
|
||||
user=owner_authenticated, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(owner_authenticated)
|
||||
for new_role in [
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.VIEWER,
|
||||
]:
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
data={"role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert (
|
||||
response.json()["role"]
|
||||
== "Cannot change the role to a non-owner role for the last owner access."
|
||||
)
|
||||
access.refresh_from_db()
|
||||
assert access.role == enums.MailDomainRoleChoices.OWNER
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.MailDomainAccessFactory(
|
||||
domain=access.domain, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
for new_role in [
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.VIEWER,
|
||||
]:
|
||||
response = client.patch(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
data={"role": new_role},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
@@ -4,6 +4,11 @@ Unit tests for the mailbox API
|
||||
|
||||
import json
|
||||
import re
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
@@ -230,7 +235,8 @@ def test_api_mailboxes__create_administrator_missing_fields():
|
||||
assert response.json() == {"secondary_email": ["This field is required."]}
|
||||
|
||||
|
||||
### SYNC TO PROVISIONING API
|
||||
### REACTING TO DIMAIL-API
|
||||
### We mock dimail's responses to avoid testing dimail's container too
|
||||
|
||||
|
||||
def test_api_mailboxes__unrelated_user_provisioning_api_not_called():
|
||||
@@ -285,6 +291,56 @@ def test_api_mailboxes__domain_viewer_provisioning_api_not_called():
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "error")
|
||||
def test_api_mailboxes__async_dimail_unauthorized(mock_error):
|
||||
"""
|
||||
Dimail should raise an error if token has been successfully granted
|
||||
but mailbox creation request returns a 403.
|
||||
i.e. user exists on dimail-api but has no permission on that domain
|
||||
"""
|
||||
# creating all needed objects
|
||||
|
||||
# this access somehow exists solely in our database but not in dimail
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
mailbox_data = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build(domain=access.domain)
|
||||
).data
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "domain_owner_token"}',
|
||||
status=status.HTTP_200_OK, # user is in dimail-api
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(
|
||||
rf".*/domains/{access.domain.name}/mailboxes/{mailbox_data['local_part']}"
|
||||
),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
|
||||
mailbox_data,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
assert mock_error.call_count == 1
|
||||
assert mock_error.call_args_list[0][0] == (
|
||||
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
|
||||
access.domain.name,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.OWNER],
|
||||
@@ -295,7 +351,7 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
|
||||
"""
|
||||
Domain owner/admin should be able to create mailboxes.
|
||||
Provisioning API should be called when owner/admin makes a call.
|
||||
Expected response contains new email and password.
|
||||
Succesfull 201 response from dimail should trigger mailbox creation on our side.
|
||||
"""
|
||||
# creating all needed objects
|
||||
access = factories.MailDomainAccessFactory(role=role)
|
||||
@@ -359,3 +415,253 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
|
||||
assert mailbox.last_name == mailbox_data["last_name"]
|
||||
assert mailbox.local_part == mailbox_data["local_part"]
|
||||
assert mailbox.secondary_email == mailbox_data["secondary_email"]
|
||||
|
||||
|
||||
@override_settings(MAIL_PROVISIONING_API_CREDENTIALS="wrongCredentials")
|
||||
def test_api_mailboxes__dimail_token_permission_denied():
|
||||
"""
|
||||
API should raise a clear "permission denied" error
|
||||
when receiving a permission denied from dimail upon requesting token.
|
||||
"""
|
||||
# creating all needed objects
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
mailbox_data = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build(domain=access.domain)
|
||||
).data
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"details": "Permission denied"}',
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
|
||||
mailbox_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
}
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
|
||||
def test_api_mailboxes__user_unrelated_to_domain():
|
||||
"""
|
||||
API should raise a clear "permission denied" when dimail returns a permission denied
|
||||
on mailbox creation. This means token was granted for this user
|
||||
but user is not allowed to modify this domain (i.e. not owner)
|
||||
"""
|
||||
# creating all needed objects
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
mailbox_data = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build(domain=access.domain)
|
||||
).data
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "domain_owner_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
|
||||
body='{"details": "Permission denied"}',
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
|
||||
mailbox_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
}
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "error")
|
||||
def test_api_mailboxes__handling_dimail_unexpected_error(mock_error):
|
||||
"""
|
||||
API should raise a clear error when dimail returns an unexpected response.
|
||||
"""
|
||||
# creating all needed objects
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
mailbox_data = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build(domain=access.domain)
|
||||
).data
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "domain_owner_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
|
||||
body='{"details": "Internal server error"}',
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
with pytest.raises(SystemError):
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
|
||||
mailbox_data,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.json() == {
|
||||
"detail": "Unexpected response from dimail: {'details': 'Internal server error'}"
|
||||
}
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
# Check error logger was called
|
||||
assert mock_error.called
|
||||
assert mock_error.call_args_list[1][0] == (
|
||||
'Unexpected response from dimail: 500 {"details": "Internal server error"}',
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "error")
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_api_mailboxes__send_correct_logger_infos(mock_info, mock_error):
|
||||
"""
|
||||
Upon requesting mailbox creation, la régie should impersonate
|
||||
querying user in dimail and log things correctly.
|
||||
"""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
mailbox_data = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build(domain=access.domain)
|
||||
).data
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "domain_owner_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
{
|
||||
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
|
||||
"password": "newpass",
|
||||
"uuid": "uuid",
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
|
||||
mailbox_data,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# user sub is sent to payload as a parameter
|
||||
assert rsps.calls[0].request.params == {"username": access.user.sub}
|
||||
|
||||
# Logger
|
||||
assert not mock_error.called
|
||||
assert mock_info.call_count == 4
|
||||
# a new empty error has been added. To be investigated
|
||||
assert mock_info.call_args_list[0][0] == (
|
||||
"Token succesfully granted by mail-provisioning API.",
|
||||
)
|
||||
assert mock_info.call_args_list[1][0] == (
|
||||
"Mailbox successfully created on domain %s by user %s",
|
||||
str(access.domain),
|
||||
access.user.sub,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_api_mailboxes__sends_new_mailbox_notification(mock_info):
|
||||
"""
|
||||
Creating a new mailbox should send confirmation email
|
||||
to secondary email.
|
||||
"""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
mailbox_data = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build(domain=access.domain)
|
||||
).data
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "domain_owner_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
{
|
||||
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
|
||||
"password": "newpass",
|
||||
"uuid": "uuid",
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
with mock.patch("django.core.mail.send_mail") as mock_send:
|
||||
client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
|
||||
mailbox_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert mock_send.call_count == 1
|
||||
assert mock_send.mock_calls[0][1][0] == "Your new mailbox information"
|
||||
assert mock_send.mock_calls[0][1][3][0] == mailbox_data["secondary_email"]
|
||||
|
||||
assert mock_info.call_count == 4
|
||||
assert mock_info.call_args_list[2][0] == (
|
||||
"Information for mailbox %s sent to %s.",
|
||||
f"{mailbox_data['local_part']}@{access.domain.name}",
|
||||
mailbox_data["secondary_email"],
|
||||
)
|
||||
|
||||
@@ -2,19 +2,12 @@
|
||||
Unit tests for the mailbox model
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.core import exceptions
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
from mailbox_manager.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -140,127 +133,20 @@ def test_models_mailboxes__cannot_be_created_for_pending_maildomain():
|
||||
factories.MailboxFactory(domain=factories.MailDomainFactory())
|
||||
|
||||
|
||||
### SYNC TO DIMAIL-API
|
||||
### REACTING TO DIMAIL-API
|
||||
### We mock dimail's responses to avoid testing dimail's container too
|
||||
|
||||
|
||||
def test_models_mailboxes__no_secret():
|
||||
"""If no secret is declared on the domain, the function should raise an error."""
|
||||
domain = factories.MailDomainEnabledFactory(secret=None)
|
||||
@override_settings(MAIL_PROVISIONING_API_CREDENTIALS=None)
|
||||
def test_models_mailboxes__dimail_no_credentials():
|
||||
"""
|
||||
If MAIL_PROVISIONING_API_CREDENTIALS setting is not configured,
|
||||
trying to create a mailbox should raise an error.
|
||||
"""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.ValidationError,
|
||||
match="Please configure your domain's secret before creating any mailbox.",
|
||||
match="Please configure MAIL_PROVISIONING_API_CREDENTIALS before creating any mailbox.",
|
||||
):
|
||||
factories.MailboxFactory(domain=domain)
|
||||
|
||||
|
||||
def test_models_mailboxes__wrong_secret():
|
||||
"""If domain secret is inaccurate, the function should raise an error."""
|
||||
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response by scim provider using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"detail": "Permission denied"}',
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body='{"detail": "Permission denied"}',
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.PermissionDenied,
|
||||
match=f"Please check secret of the mail domain {domain.name}",
|
||||
):
|
||||
mailbox = factories.MailboxFactory(use_mock=False, domain=domain)
|
||||
# Payload sent to mailbox provider
|
||||
payload = json.loads(rsps.calls[1].request.body)
|
||||
assert payload == {
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
"givenName": mailbox.first_name,
|
||||
"surName": mailbox.last_name,
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "error")
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_models_mailboxes__create_mailbox_success(mock_info, mock_error):
|
||||
"""Creating a mailbox sends the expected information and get expected response before saving."""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
|
||||
# generate mailbox data before mailbox, to mock responses
|
||||
mailbox_data = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build(domain=domain)
|
||||
).data
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "domain_owner_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
{
|
||||
"email": f"{mailbox_data['local_part']}@{domain.name}",
|
||||
"password": "newpass",
|
||||
"uuid": "uuid",
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
mailbox = factories.MailboxFactory(
|
||||
use_mock=False, local_part=mailbox_data["local_part"], domain=domain
|
||||
)
|
||||
|
||||
# Check headers
|
||||
headers = rsps.calls[1].request.headers
|
||||
# assert "Authorization" not in headers
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
# Payload sent to mailbox provider
|
||||
payload = json.loads(rsps.calls[1].request.body)
|
||||
assert payload == {
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
"givenName": mailbox.first_name,
|
||||
"surName": mailbox.last_name,
|
||||
}
|
||||
|
||||
# Logger
|
||||
assert not mock_error.called
|
||||
assert mock_info.call_count == 2
|
||||
assert mock_info.call_args_list[0][0] == (
|
||||
"Token succesfully granted by mail-provisioning API.",
|
||||
)
|
||||
assert mock_info.call_args_list[1][0] == (
|
||||
"Mailbox successfully created on domain %s",
|
||||
domain.name,
|
||||
)
|
||||
assert mock_info.call_args_list[1][1] == (
|
||||
{
|
||||
"extra": {
|
||||
"response": str(
|
||||
{
|
||||
"email": f"{mailbox.local_part}@{domain.name}",
|
||||
"password": "newpass",
|
||||
"uuid": "uuid",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""A minimalist client to synchronize with mailbox provisioning API."""
|
||||
|
||||
import smtplib
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import exceptions
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import exceptions, mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from rest_framework import status
|
||||
@@ -29,43 +33,59 @@ class DimailAPIClient:
|
||||
"""A dimail-API client."""
|
||||
|
||||
API_URL = settings.MAIL_PROVISIONING_API_URL
|
||||
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
|
||||
|
||||
def get_headers(self, domain):
|
||||
"""Build header dict from domain object."""
|
||||
# self.secret is the encoded basic auth, to request a new token from dimail-api
|
||||
def get_headers(self, user_sub=None):
|
||||
"""
|
||||
Build headers dictionary. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
|
||||
to get a token from dimail /token/ endpoint.
|
||||
If provided, request user' sub is used for la regie to log in as this user,
|
||||
thus allowing for more precise logs.
|
||||
"""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
params = None
|
||||
|
||||
if user_sub:
|
||||
params = {"username": str(user_sub)}
|
||||
|
||||
response = requests.get(
|
||||
f"{self.API_URL}/token/",
|
||||
headers={"Authorization": f"Basic {domain.secret}"},
|
||||
timeout=status.HTTP_200_OK,
|
||||
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
|
||||
params=params,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if response.json() == "{'detail': 'Permission denied'}":
|
||||
raise exceptions.PermissionDenied(
|
||||
"This secret does not allow for a new token."
|
||||
)
|
||||
|
||||
if "access_token" in response.json():
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
headers["Authorization"] = f"Bearer {response.json()['access_token']}"
|
||||
logger.info("Token succesfully granted by mail-provisioning API.")
|
||||
return headers
|
||||
|
||||
return headers
|
||||
if response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
logger.error(
|
||||
"[DIMAIL] 403 Forbidden: Could not retrieve a token,\
|
||||
please check 'MAIL_PROVISIONING_API_CREDENTIALS' setting.",
|
||||
)
|
||||
raise exceptions.PermissionDenied(
|
||||
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
)
|
||||
|
||||
def send_mailbox_request(self, mailbox):
|
||||
return self.pass_dimail_unexpected_response(response)
|
||||
|
||||
def send_mailbox_request(self, mailbox, user_sub=None):
|
||||
"""Send a CREATE mailbox request to mail provisioning API."""
|
||||
|
||||
payload = {
|
||||
"givenName": mailbox.first_name,
|
||||
"surName": mailbox.last_name,
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
"givenName": mailbox["first_name"],
|
||||
"surName": mailbox["last_name"],
|
||||
"displayName": f"{mailbox['first_name']} {mailbox['last_name']}",
|
||||
}
|
||||
headers = self.get_headers(user_sub)
|
||||
|
||||
try:
|
||||
response = session.post(
|
||||
f"{self.API_URL}/domains/{mailbox.domain}/mailboxes/{mailbox.local_part}/",
|
||||
f"{self.API_URL}/domains/{mailbox['domain']}/mailboxes/{mailbox['local_part']}",
|
||||
json=payload,
|
||||
headers=self.get_headers(mailbox.domain),
|
||||
headers=headers,
|
||||
verify=True,
|
||||
timeout=10,
|
||||
)
|
||||
@@ -78,27 +98,68 @@ class DimailAPIClient:
|
||||
raise error
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
extra = {"response": response.content.decode("utf-8")}
|
||||
# This a temporary broken solution. Password will soon be sent
|
||||
# from OX servers but their prod is not ready.
|
||||
# In the meantime, we log mailbox info (including password !)
|
||||
logger.info(
|
||||
"Mailbox successfully created on domain %s",
|
||||
mailbox.domain.name,
|
||||
extra=extra,
|
||||
"Mailbox successfully created on domain %s by user %s",
|
||||
str(mailbox["domain"]),
|
||||
user_sub,
|
||||
)
|
||||
elif response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
return response
|
||||
|
||||
if response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
logger.error(
|
||||
"[DIMAIL] 403 Forbidden: please check the mail domain secret of %s",
|
||||
mailbox.domain.name,
|
||||
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
|
||||
str(mailbox["domain"]),
|
||||
)
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Please check secret of the mail domain {mailbox.domain.name}"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Unexpected response: %s",
|
||||
response.content.decode("utf-8"),
|
||||
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
)
|
||||
|
||||
return response
|
||||
return self.pass_dimail_unexpected_response(response)
|
||||
|
||||
def pass_dimail_unexpected_response(self, response):
|
||||
"""Raise error when encountering an unexpected error in dimail."""
|
||||
error_content = response.content.decode("utf-8")
|
||||
|
||||
logger.error(
|
||||
"[DIMAIL] unexpected error : %s %s", response.status_code, error_content
|
||||
)
|
||||
raise SystemError(
|
||||
f"Unexpected response from dimail: {response.status_code} {error_content}"
|
||||
)
|
||||
|
||||
def send_new_mailbox_notification(self, recipient, mailbox_data):
|
||||
"""
|
||||
Send email to confirm mailbox creation
|
||||
and send new mailbox information.
|
||||
"""
|
||||
|
||||
template_vars = {
|
||||
"title": _("Your new mailbox information"),
|
||||
"site": Site.objects.get_current(),
|
||||
"webmail_url": settings.WEBMAIL_URL,
|
||||
"mailbox_data": mailbox_data,
|
||||
}
|
||||
|
||||
msg_html = render_to_string("mail/html/new_mailbox.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/new_mailbox.txt", template_vars)
|
||||
|
||||
try:
|
||||
mail.send_mail(
|
||||
template_vars["title"],
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[recipient],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info(
|
||||
"Information for mailbox %s sent to %s.",
|
||||
mailbox_data["email"],
|
||||
recipient,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error(
|
||||
"Mailbox confirmation email to %s was not sent: %s",
|
||||
recipient,
|
||||
exception,
|
||||
)
|
||||
|
||||
@@ -271,7 +271,6 @@ class Base(Configuration):
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
@@ -414,13 +413,28 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
OIDC_TIMEOUT = values.Value(None, environ_name="OIDC_TIMEOUT", environ_prefix=None)
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# mailboxes provisioning API
|
||||
# MAILBOX-PROVISIONING API
|
||||
WEBMAIL_URL = values.Value(
|
||||
default=None,
|
||||
environ_name="WEBMAIL_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MAIL_PROVISIONING_API_URL = values.Value(
|
||||
default="https://api.dev.ox.numerique.gouv.fr",
|
||||
default="http://host.docker.internal:8001",
|
||||
environ_name="MAIL_PROVISIONING_API_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MAIL_PROVISIONING_API_CREDENTIALS = values.Value(
|
||||
default=None,
|
||||
environ_name="MAIL_PROVISIONING_API_CREDENTIALS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
FEATURES = {
|
||||
"TEAMS": values.BooleanValue(
|
||||
@@ -473,6 +487,8 @@ class Base(Configuration):
|
||||
environment=cls.__name__.lower(),
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
default_integrations=False,
|
||||
)
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
@@ -574,6 +590,9 @@ class Test(Base):
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||
|
||||
# this is a dev credentials for mail provisioning API
|
||||
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
|
||||
|
||||
|
||||
class ContinuousIntegration(Test):
|
||||
"""
|
||||
@@ -608,6 +627,14 @@ class Production(Base):
|
||||
#
|
||||
# In other cases, you should comment the following line to avoid security issues.
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_HSTS_SECONDS = 60
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_REDIRECT_EXEMPT = [
|
||||
"^__lbheartbeat__",
|
||||
"^__heartbeat__",
|
||||
]
|
||||
|
||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||
CSRF_COOKIE_SECURE = True
|
||||
@@ -654,6 +681,10 @@ class Production(Base):
|
||||
},
|
||||
},
|
||||
}
|
||||
SENTRY_DSN = values.Value(
|
||||
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
|
||||
environ_name="SENTRY_DSN",
|
||||
)
|
||||
|
||||
|
||||
class Feature(Production):
|
||||
|
||||
+16
-16
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "people"
|
||||
version = "1.0.2"
|
||||
version = "1.4.2"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,31 +25,31 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.7",
|
||||
"boto3==1.35.44",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-cors-headers==4.5.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.0.8",
|
||||
"redis==5.1.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1",
|
||||
"django==5.1.2",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.9",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.1",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.9.0",
|
||||
"joserfc==1.0.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.13.0",
|
||||
"sentry-sdk[django]==2.17.0",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.7.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
@@ -66,18 +66,18 @@ dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.26.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.2.6",
|
||||
"ipython==8.28.0",
|
||||
"pyfakefs==5.7.1",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.1",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-django==4.8.0",
|
||||
"pytest==8.3.2",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.3",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.6.2",
|
||||
"types-requests==2.32.0.20240712",
|
||||
"ruff==0.7.0",
|
||||
"types-requests==2.32.0.20241016",
|
||||
"freezegun==1.5.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const path = require('path');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
@@ -5,6 +7,9 @@ const nextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
sassOptions: {
|
||||
includePaths: [path.join(__dirname, 'src')],
|
||||
},
|
||||
compiler: {
|
||||
// Enables the styled-components SWC transform
|
||||
styledComponents: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-desk",
|
||||
"version": "1.0.2",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -18,17 +18,18 @@
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hookform/resolvers": "3.9.0",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@tanstack/react-query": "5.52.3",
|
||||
"i18next": "23.14.0",
|
||||
"@tanstack/react-query": "5.56.2",
|
||||
"i18next": "23.15.1",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "14.2.7",
|
||||
"next": "14.2.13",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.3.3",
|
||||
"react-dom": "*",
|
||||
"react-hook-form": "7.53.0",
|
||||
"react-i18next": "15.0.1",
|
||||
"react-select": "5.8.0",
|
||||
"react-i18next": "15.0.2",
|
||||
"react-select": "5.8.1",
|
||||
"sass": "1.80.3",
|
||||
"styled-components": "6.1.13",
|
||||
"zod": "3.23.8",
|
||||
"zustand": "4.5.5"
|
||||
@@ -36,16 +37,16 @@
|
||||
"devDependencies": {
|
||||
"@hookform/devtools": "4.3.1",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.52.3",
|
||||
"@tanstack/react-query-devtools": "5.58.0",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.5.0",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/lodash": "4.17.9",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.4",
|
||||
"@types/react": "18.3.10",
|
||||
"@types/react-dom": "*",
|
||||
"dotenv": "16.4.5",
|
||||
"eslint-config-people": "*",
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('Page', () => {
|
||||
|
||||
it('checks Page rendering with team feature', () => {
|
||||
useConfigStore.setState({
|
||||
config: { FEATURES: { TEAMS: true }, LANGUAGES: [] },
|
||||
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: true }, LANGUAGES: [] },
|
||||
});
|
||||
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
@@ -31,7 +31,7 @@ describe('Page', () => {
|
||||
|
||||
it('checks Page rendering without team feature', () => {
|
||||
useConfigStore.setState({
|
||||
config: { FEATURES: { TEAMS: false }, LANGUAGES: [] },
|
||||
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: false }, LANGUAGES: [] },
|
||||
});
|
||||
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { APIError } from '@/api';
|
||||
|
||||
import {
|
||||
parseAPIError,
|
||||
parseAPIErrorCause,
|
||||
parseServerAPIError,
|
||||
} from '../parseAPIError';
|
||||
|
||||
describe('parseAPIError', () => {
|
||||
const handleErrorMock = jest.fn();
|
||||
const handleServerErrorMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle specific API error and return no unhandled causes', () => {
|
||||
const error = new APIError('client error', {
|
||||
cause: ['Mail domain with this name already exists.'],
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const result = parseAPIError({
|
||||
error,
|
||||
errorParams: [
|
||||
[
|
||||
['Mail domain with this name already exists.'],
|
||||
'This domain already exists.',
|
||||
handleErrorMock,
|
||||
],
|
||||
],
|
||||
serverErrorParams: ['Server error', handleServerErrorMock],
|
||||
});
|
||||
|
||||
expect(handleErrorMock).toHaveBeenCalled();
|
||||
expect(result).toEqual(['This domain already exists.']);
|
||||
});
|
||||
|
||||
it('should return unhandled causes when no match is found', () => {
|
||||
const error = new APIError('client error', {
|
||||
cause: ['Unhandled error'],
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const result = parseAPIError({
|
||||
error,
|
||||
errorParams: [
|
||||
[
|
||||
['Mail domain with this name already exists.'],
|
||||
'This domain already exists.',
|
||||
handleErrorMock,
|
||||
],
|
||||
],
|
||||
serverErrorParams: ['Server error', handleServerErrorMock],
|
||||
});
|
||||
|
||||
expect(handleErrorMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(['Unhandled error']);
|
||||
});
|
||||
|
||||
it('should handle server errors correctly and prepend server error message', () => {
|
||||
const error = new APIError('server error', { status: 500 });
|
||||
|
||||
const result = parseAPIError({
|
||||
error,
|
||||
errorParams: undefined,
|
||||
serverErrorParams: ['Server error occurred', handleServerErrorMock],
|
||||
});
|
||||
|
||||
expect(handleServerErrorMock).toHaveBeenCalled();
|
||||
expect(result).toEqual(['Server error occurred']);
|
||||
});
|
||||
|
||||
it('should handle absence of errors gracefully', () => {
|
||||
const result = parseAPIError({
|
||||
error: null,
|
||||
errorParams: [
|
||||
[
|
||||
['Mail domain with this name already exists.'],
|
||||
'This domain already exists.',
|
||||
handleErrorMock,
|
||||
],
|
||||
],
|
||||
serverErrorParams: ['Server error', handleServerErrorMock],
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAPIErrorCause', () => {
|
||||
it('should handle specific errors and call handleError', () => {
|
||||
const handleErrorMock = jest.fn();
|
||||
const causes = ['Mail domain with this name already exists.'];
|
||||
|
||||
const errorParams: [string[], string, () => void][] = [
|
||||
[
|
||||
['Mail domain with this name already exists.'],
|
||||
'This domain already exists.',
|
||||
handleErrorMock,
|
||||
],
|
||||
];
|
||||
|
||||
const result = parseAPIErrorCause(
|
||||
new APIError('client error', { cause: causes, status: 400 }),
|
||||
errorParams,
|
||||
);
|
||||
|
||||
expect(handleErrorMock).toHaveBeenCalled();
|
||||
expect(result).toEqual(['This domain already exists.']);
|
||||
});
|
||||
|
||||
it('should handle multiple causes and return unhandled causes', () => {
|
||||
const handleErrorMock = jest.fn();
|
||||
const causes = [
|
||||
'Mail domain with this name already exists.',
|
||||
'Unhandled error',
|
||||
];
|
||||
|
||||
const errorParams: [string[], string, () => void][] = [
|
||||
[
|
||||
['Mail domain with this name already exists.'],
|
||||
'This domain already exists.',
|
||||
handleErrorMock,
|
||||
],
|
||||
];
|
||||
|
||||
const result = parseAPIErrorCause(
|
||||
new APIError('client error', { cause: causes, status: 400 }),
|
||||
errorParams,
|
||||
);
|
||||
|
||||
expect(handleErrorMock).toHaveBeenCalled();
|
||||
expect(result).toEqual(['This domain already exists.', 'Unhandled error']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseServerAPIError', () => {
|
||||
const handleServerErrorMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return the server error message and handle callback', () => {
|
||||
const result = parseServerAPIError(['Server error', handleServerErrorMock]);
|
||||
|
||||
expect(result).toEqual('Server error');
|
||||
expect(handleServerErrorMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return only the server error message when no callback is provided', () => {
|
||||
const result = parseServerAPIError(['Server error', undefined]);
|
||||
|
||||
expect(result).toEqual('Server error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useDebounce } from '../useDebounce';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('useDebounce', () => {
|
||||
it('should return the initial value immediately', () => {
|
||||
const { result } = renderHook(() => useDebounce('initial value'));
|
||||
|
||||
expect(result.current).toBe('initial value');
|
||||
});
|
||||
|
||||
it('should return debounced value after default 500ms delay', () => {
|
||||
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
|
||||
initialProps: { value: 'initial' },
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
rerender({ value: 'updated' });
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('updated');
|
||||
});
|
||||
|
||||
it('should reset the debounce timer if value changes before delay', () => {
|
||||
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
|
||||
initialProps: { value: 'initial' },
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
rerender({ value: 'updated' });
|
||||
rerender({ value: 'updated again' });
|
||||
|
||||
// Fast forward 400ms (less than debounce delay), value should still be 'initial'
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
// Fast forward 500ms (total: 900ms), value should be 'updated again'
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('updated again');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { useDebounce } from './useDebounce';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MS_DEBOUNCE = 500;
|
||||
export const useDebounce = (value: string, delay: number = MS_DEBOUNCE) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
@@ -3,3 +3,4 @@ export * from './conf';
|
||||
export * from './fetchApi';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
export * from './hooks';
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { APIError } from '@/api/index';
|
||||
|
||||
type ErrorCallback = () => void;
|
||||
|
||||
// Type for the error tuple format [causes, message, handleError]
|
||||
type ErrorTuple = [string[], string, ErrorCallback | undefined];
|
||||
|
||||
// Server error tuple [defaultMessage, handleError]
|
||||
type ServerErrorTuple = [string, ErrorCallback | undefined];
|
||||
|
||||
/**
|
||||
* @function parseAPIError
|
||||
* @description function to centralize APIError handling to treat discovered errors
|
||||
* and error type 500 with default behavior using a simplified tuple structure.
|
||||
* @param error - APIError object
|
||||
* @param errorParams - Array of tuples: each contains an array of causes, a message, and an optional callback function.
|
||||
* @param serverErrorParams - A tuple for server error handling: [defaultMessage, handleError]
|
||||
* @returns Array of error messages or undefined
|
||||
*/
|
||||
export const parseAPIError = ({
|
||||
error,
|
||||
errorParams,
|
||||
serverErrorParams,
|
||||
}: {
|
||||
error: APIError | null;
|
||||
errorParams?: ErrorTuple[];
|
||||
serverErrorParams?: ServerErrorTuple;
|
||||
}): string[] | undefined => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse known error causes using the tuple structure
|
||||
const errorCauses =
|
||||
error.cause?.length && errorParams
|
||||
? parseAPIErrorCause(error, errorParams)
|
||||
: undefined;
|
||||
|
||||
// Check if it's a server error (500) and handle that case
|
||||
const serverErrorCause =
|
||||
(error?.status === 500 || !error?.status) && serverErrorParams
|
||||
? parseServerAPIError(serverErrorParams)
|
||||
: undefined;
|
||||
|
||||
// Combine the causes and return
|
||||
const causes: string[] = errorCauses ? [...errorCauses] : [];
|
||||
if (serverErrorCause) {
|
||||
causes.unshift(serverErrorCause);
|
||||
}
|
||||
|
||||
return causes.length ? causes : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* @function parseAPIErrorCause
|
||||
* @description Processes known API error causes using the tuple structure.
|
||||
* @param error - APIError object
|
||||
* @param errorParams - Array of tuples: each contains an array of causes, a message, and an optional callback function.
|
||||
* @returns Array of error messages
|
||||
*/
|
||||
export const parseAPIErrorCause = (
|
||||
error: APIError,
|
||||
errorParams: ErrorTuple[],
|
||||
): string[] | undefined => {
|
||||
if (!error.cause) {
|
||||
return;
|
||||
}
|
||||
|
||||
return error.cause.reduce((causes: string[], cause: string) => {
|
||||
// Find the matching error tuple
|
||||
const matchedError = errorParams.find(([errorCauses]) =>
|
||||
errorCauses.some((knownCause) => new RegExp(knownCause, 'i').test(cause)),
|
||||
);
|
||||
|
||||
if (matchedError) {
|
||||
const [, message, handleError] = matchedError;
|
||||
causes.push(message);
|
||||
|
||||
if (handleError) {
|
||||
handleError();
|
||||
}
|
||||
} else {
|
||||
// If no match is found, add the original cause
|
||||
causes.push(cause);
|
||||
}
|
||||
|
||||
return causes;
|
||||
}, []);
|
||||
};
|
||||
|
||||
/**
|
||||
* @function parseServerAPIError
|
||||
* @description Handles server errors (500) and adds the default message.
|
||||
* @param serverErrorParams - Tuple [defaultMessage, handleError]
|
||||
* @returns Server error message
|
||||
*/
|
||||
export const parseServerAPIError = ([
|
||||
defaultMessage,
|
||||
handleError,
|
||||
]: ServerErrorTuple): string => {
|
||||
if (handleError) {
|
||||
handleError();
|
||||
}
|
||||
|
||||
return defaultMessage;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Modal as CunninghamModal,
|
||||
ModalProps,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
// Define a wrapper component that extends ModalProps to accept the same props as the Modal
|
||||
export const Modal: React.FC<ModalProps> = ({ children, ...props }) => {
|
||||
// Apply the hook here once for all modals
|
||||
usePreventFocusVisible(['.c__modal__content']);
|
||||
|
||||
return <CunninghamModal {...props}>{children}</CunninghamModal>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description used to prevent elements to be navigable by keyboard when only a DOM mutation causes the elements to be
|
||||
* in the document
|
||||
* @see https://github.com/numerique-gouv/people/pull/379
|
||||
*/
|
||||
export const usePreventFocusVisible = (elements: string[]) => {
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
mutationsList.forEach(() => {
|
||||
elements.forEach((selector) =>
|
||||
document.querySelector(selector)?.setAttribute('tabindex', '-1'),
|
||||
);
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [elements]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ModalSize } from '@openfun/cunningham-react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { Modal, usePreventFocusVisible } from '../Modal';
|
||||
|
||||
describe('usePreventFocusVisible hook', () => {
|
||||
const TestComponent = () => {
|
||||
usePreventFocusVisible(['.test-element']);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="test-element">Test Element</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const originalMutationObserver = global.MutationObserver;
|
||||
|
||||
const mockDisconnect = jest.fn();
|
||||
const mutationObserverMock = jest.fn(function MutationObserver(
|
||||
callback: MutationCallback,
|
||||
) {
|
||||
this.observe = () => {
|
||||
callback([{ type: 'childList' }] as MutationRecord[], this);
|
||||
};
|
||||
|
||||
this.disconnect = mockDisconnect;
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
beforeAll(
|
||||
() =>
|
||||
(global.MutationObserver =
|
||||
mutationObserverMock as unknown as typeof MutationObserver),
|
||||
);
|
||||
|
||||
afterAll(() => (global.MutationObserver = originalMutationObserver));
|
||||
|
||||
test('sets tabindex to -1 on the target elements', () => {
|
||||
const { unmount } = render(<TestComponent />);
|
||||
|
||||
const targetElement = screen.getByText('Test Element');
|
||||
|
||||
expect(targetElement).toHaveAttribute('tabindex', '-1');
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockDisconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal', () => {
|
||||
test('applies usePreventFocusVisible and sets tabindex', async () => {
|
||||
render(
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={<h3>Test Modal Title</h3>}
|
||||
leftActions={<button>Cancel</button>}
|
||||
rightActions={<button>Submit</button>}
|
||||
>
|
||||
<p>Modal content</p>
|
||||
</Modal>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
const modalContent = document.querySelector('.c__modal__content');
|
||||
/* eslint-enable testing-library/no-node-access */
|
||||
|
||||
await waitFor(() => {
|
||||
expect(modalContent).toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
import style from './custom-tabs.module.scss';
|
||||
|
||||
type TabsOption = {
|
||||
ariaLabel?: string;
|
||||
label: string;
|
||||
iconName?: string;
|
||||
id?: string;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
tabs: TabsOption[];
|
||||
};
|
||||
|
||||
export const CustomTabs = ({ tabs }: Props) => {
|
||||
return (
|
||||
<div className={style.customTabsContainer}>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
{tabs.map((tab) => {
|
||||
const id = tab.id ?? tab.label;
|
||||
return (
|
||||
<Tab key={id} aria-label={tab.ariaLabel} id={id}>
|
||||
<Box $direction="row" $align="center" $gap="5px">
|
||||
{tab.iconName && (
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
{tab.iconName}
|
||||
</span>
|
||||
)}
|
||||
{tab.label}
|
||||
</Box>
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</TabList>
|
||||
|
||||
{tabs.map((tab) => {
|
||||
const id = tab.id ?? tab.label;
|
||||
return (
|
||||
<TabPanel key={id} id={id}>
|
||||
{tab.content}
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
.customTabsContainer {
|
||||
:global {
|
||||
.react-aria-TabList {
|
||||
display: flex;
|
||||
|
||||
&[data-orientation='horizontal'] {
|
||||
.react-aria-Tab {
|
||||
border-bottom: 2px solid var(--c--theme--colors--greyscale-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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] {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.react-aria-TabPanel {
|
||||
margin-top: 4px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&[data-focus-visible] {
|
||||
outline: 2px solid var(--c--theme--colors--primary-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,30 +9,37 @@ import { useConfigStore } from '../config';
|
||||
jest.mock('next/navigation', () => ({
|
||||
...jest.requireActual('next/navigation'),
|
||||
usePathname: () => '/',
|
||||
useRouter: () => ({
|
||||
push: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('MainLayout', () => {
|
||||
it('checks menu rendering with team feature', () => {
|
||||
useConfigStore.setState({
|
||||
config: { FEATURES: { TEAMS: true }, LANGUAGES: [] },
|
||||
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: true }, LANGUAGES: [] },
|
||||
});
|
||||
|
||||
render(<MainLayout />, { wrapper: AppWrapper });
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
screen.getByRole('button', {
|
||||
name: /Teams button/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
screen.getByRole('button', {
|
||||
name: /Mail Domains button/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks menu rendering without team feature', () => {
|
||||
useConfigStore.setState({
|
||||
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: false }, LANGUAGES: [] },
|
||||
});
|
||||
|
||||
render(<MainLayout />, { wrapper: AppWrapper });
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface Config {
|
||||
LANGUAGES: [string, string][];
|
||||
RELEASE: string;
|
||||
FEATURES: {
|
||||
TEAMS: boolean;
|
||||
};
|
||||
|
||||
@@ -484,11 +484,6 @@ input:-webkit-autofill:focus {
|
||||
/**
|
||||
* Modal
|
||||
*/
|
||||
.c__modal:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.c__modal__backdrop {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, LogoGouv, StyledLink, Text } from '@/components';
|
||||
import { useConfigStore } from '@/core';
|
||||
|
||||
import IconLink from './assets/external-link.svg';
|
||||
|
||||
@@ -16,6 +17,7 @@ const BlueStripe = styled.div`
|
||||
|
||||
export const Footer = () => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useConfigStore();
|
||||
|
||||
return (
|
||||
<Box $position="relative" as="footer">
|
||||
@@ -94,7 +96,7 @@ export const Footer = () => {
|
||||
$padding={{ top: 'tiny' }}
|
||||
$css="
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
border-top: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
column-gap: 1rem;
|
||||
row-gap: .5rem;
|
||||
"
|
||||
@@ -138,6 +140,7 @@ export const Footer = () => {
|
||||
</StyledLink>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
$size="m"
|
||||
@@ -145,6 +148,12 @@ export const Footer = () => {
|
||||
$variation="600"
|
||||
$display="inline"
|
||||
>
|
||||
{config?.RELEASE && (
|
||||
<>
|
||||
{t(`Version: {{release}}`, { release: config?.RELEASE })} •
|
||||
</>
|
||||
)}
|
||||
|
||||
<Trans>
|
||||
Unless otherwise stated, all content on this site is under
|
||||
<StyledLink
|
||||
|
||||
@@ -7,32 +7,35 @@ import { useAuthStore } from '@/core/auth';
|
||||
|
||||
export const AccountDropdown = () => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useAuthStore();
|
||||
const { userData, logout } = useAuthStore();
|
||||
|
||||
const userName = userData?.name || t('No Username');
|
||||
return (
|
||||
<DropButton
|
||||
aria-label={t('My account')}
|
||||
button={
|
||||
<Box $flex $direction="row" $align="center">
|
||||
<Text $theme="primary">{t('My account')}</Text>
|
||||
<Text $theme="primary">{userName}</Text>
|
||||
<Text className="material-icons" $theme="primary" aria-hidden="true">
|
||||
arrow_drop_down
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={logout}
|
||||
color="primary-text"
|
||||
icon={
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
logout
|
||||
</span>
|
||||
}
|
||||
aria-label={t('Logout')}
|
||||
>
|
||||
<Text $weight="normal">{t('Logout')}</Text>
|
||||
</Button>
|
||||
<Box $css="display: flex; direction: column; gap: 0.5rem">
|
||||
<Button
|
||||
onClick={logout}
|
||||
key="logout"
|
||||
color="primary-text"
|
||||
icon={
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
logout
|
||||
</span>
|
||||
}
|
||||
aria-label={t('Logout')}
|
||||
>
|
||||
<Text $weight="normal">{t('Logout')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</DropButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { AccountDropdown } from '../AccountDropdown';
|
||||
|
||||
describe('AccountDropdown', () => {
|
||||
const mockLogout = jest.fn();
|
||||
|
||||
const renderAccountDropdown = () =>
|
||||
render(<AccountDropdown />, { wrapper: AppWrapper });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAuthStore.setState({
|
||||
userData: {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
},
|
||||
logout: mockLogout,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the user name correctly', async () => {
|
||||
renderAccountDropdown();
|
||||
|
||||
expect(await screen.findByText('Test User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "No Username" when userData name is missing', () => {
|
||||
useAuthStore.setState({
|
||||
userData: {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
logout: mockLogout,
|
||||
});
|
||||
|
||||
renderAccountDropdown();
|
||||
expect(screen.getByText('No Username')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the dropdown and shows logout button when clicked', async () => {
|
||||
renderAccountDropdown();
|
||||
|
||||
const dropButton = await screen.findByText('Test User');
|
||||
await userEvent.click(dropButton);
|
||||
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls logout function when logout button is clicked', async () => {
|
||||
renderAccountDropdown();
|
||||
|
||||
const dropButton = await screen.findByText('Test User');
|
||||
await userEvent.click(dropButton);
|
||||
|
||||
const logoutButton = screen.getByLabelText('Logout');
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(mockLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Select } from '@openfun/cunningham-react';
|
||||
import Image from 'next/image';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -60,6 +60,19 @@ export const LanguagePicker = () => {
|
||||
}));
|
||||
}, [languages]);
|
||||
|
||||
/**
|
||||
* @description prevent select div to receive focus on keyboard navigation so the focus goes directly to inner button
|
||||
* @see https://github.com/numerique-gouv/people/pull/379
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
document
|
||||
.querySelector('.c__select-language-picker .c__select__wrapper')
|
||||
?.setAttribute('tabindex', '-1');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SelectStyled
|
||||
label={t('Language')}
|
||||
@@ -67,7 +80,7 @@ export const LanguagePicker = () => {
|
||||
clearable={false}
|
||||
hideLabel
|
||||
defaultValue={i18n.language}
|
||||
className="c_select__no_bg"
|
||||
className="c_select__no_bg c__select-language-picker"
|
||||
options={optionsPicker}
|
||||
onChange={(e) => {
|
||||
i18n.changeLanguage(e.target.value as string).catch((err) => {
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { useRouter as useNavigate } from 'next/navigation';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { AccessesContent } from '@/features/mail-domains/access-management';
|
||||
import { Role } from '@/features/mail-domains/domains';
|
||||
import MailDomainAccessesPage from '@/pages/mail-domains/[slug]/accesses';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@/features/mail-domains/access-management/components/AccessesContent',
|
||||
() => ({
|
||||
AccessesContent: jest.fn(() => <div>AccessContent</div>),
|
||||
}),
|
||||
);
|
||||
|
||||
describe('MailDomainAccessesPage', () => {
|
||||
const mockRouterReplace = jest.fn();
|
||||
const mockNavigate = { replace: mockRouterReplace };
|
||||
const mockRouter = {
|
||||
query: { slug: 'example-slug' },
|
||||
};
|
||||
|
||||
(useRouter as jest.Mock).mockReturnValue(mockRouter);
|
||||
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.reset();
|
||||
(useRouter as jest.Mock).mockReturnValue(mockRouter);
|
||||
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
const renderPage = () => {
|
||||
render(<MailDomainAccessesPage />, { wrapper: AppWrapper });
|
||||
};
|
||||
|
||||
it('renders loader while loading', () => {
|
||||
// Simulate a never-resolving promise to mock loading
|
||||
fetchMock.mock(
|
||||
`end:/mail-domains/${mockRouter.query.slug}/`,
|
||||
new Promise(() => {}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error message when there is an error', async () => {
|
||||
fetchMock.mock(`end:/mail-domains/${mockRouter.query.slug}/`, {
|
||||
status: 500,
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Something bad happens, please retry.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to 404 page if the domain is not found', async () => {
|
||||
fetchMock.mock(
|
||||
`end:/mail-domains/${mockRouter.query.slug}/`,
|
||||
{
|
||||
body: { detail: 'Not found' },
|
||||
status: 404,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith('/404');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the AccessesContent when data is available', async () => {
|
||||
const mockMailDomain = {
|
||||
id: '1-1-1-1-1',
|
||||
name: 'example.com',
|
||||
slug: 'example-com',
|
||||
status: 'enabled',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.mock(`end:/mail-domains/${mockRouter.query.slug}/`, {
|
||||
body: mockMailDomain,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AccessContent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(AccessesContent).toHaveBeenCalledWith(
|
||||
{
|
||||
mailDomain: mockMailDomain,
|
||||
currentRole: Role.OWNER,
|
||||
},
|
||||
{}, // adding this empty object is necessary to load jest context
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error when slug is invalid', () => {
|
||||
console.error = jest.fn(); // Suppress expected error in jest logs
|
||||
|
||||
(useRouter as jest.Mock).mockReturnValue({
|
||||
query: { slug: ['invalid-array-slug-in-array'] },
|
||||
});
|
||||
|
||||
expect(() => renderPage()).toThrow('Invalid mail domain slug');
|
||||
});
|
||||
});
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import {
|
||||
deleteMailDomainAccess,
|
||||
useDeleteMailDomainAccess,
|
||||
} from '../useDeleteMailDomainAccess';
|
||||
|
||||
describe('deleteMailDomainAccess', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('deletes the access successfully', async () => {
|
||||
fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
|
||||
status: 204, // No content status
|
||||
});
|
||||
|
||||
await deleteMailDomainAccess({
|
||||
slug: 'example-slug',
|
||||
accessId: '1-1-1-1-1',
|
||||
});
|
||||
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
'/mail-domains/example-slug/accesses/1-1-1-1-1/',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when the API call fails', async () => {
|
||||
fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
|
||||
status: 500,
|
||||
body: { cause: ['Internal server error'] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteMailDomainAccess({
|
||||
slug: 'example-slug',
|
||||
accessId: '1-1-1-1-1',
|
||||
}),
|
||||
).rejects.toThrow(APIError);
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteMailDomainAccess', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('deletes the access and calls onSuccess callback', async () => {
|
||||
fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
|
||||
status: 204, // No content status
|
||||
});
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDeleteMailDomainAccess({ onSuccess }),
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
result.current.mutate({
|
||||
slug: 'example-slug',
|
||||
accessId: '1-1-1-1-1',
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
|
||||
await waitFor(() =>
|
||||
expect(onSuccess).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
{ slug: 'example-slug', accessId: '1-1-1-1-1' },
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
'/mail-domains/example-slug/accesses/1-1-1-1-1/',
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onError when the API fails', async () => {
|
||||
fetchMock.deleteOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
|
||||
status: 500,
|
||||
body: { cause: ['Internal server error'] },
|
||||
});
|
||||
|
||||
const onError = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDeleteMailDomainAccess({ onError }),
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
result.current.mutate({
|
||||
slug: 'example-slug',
|
||||
accessId: '1-1-1-1-1',
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
|
||||
await waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { Role } from '../../../domains';
|
||||
import { Access } from '../../types';
|
||||
import {
|
||||
getMailDomainAccesses,
|
||||
useMailDomainAccesses,
|
||||
} from '../useMailDomainAccesses';
|
||||
|
||||
const mockAccess: Access = {
|
||||
id: '1-1-1-1-1',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '2-1-1-1-1',
|
||||
name: 'username1',
|
||||
email: 'user1@test.com',
|
||||
},
|
||||
can_set_role_to: [Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
describe('getMailDomainAccesses', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('fetches the list of accesses successfully', async () => {
|
||||
const mockResponse = {
|
||||
count: 2,
|
||||
results: [
|
||||
mockAccess,
|
||||
{
|
||||
id: '2',
|
||||
role: Role.VIEWER,
|
||||
user: { id: '12', name: 'username2', email: 'user2@test.com' },
|
||||
can_set_role_to: [Role.VIEWER],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', {
|
||||
status: 200,
|
||||
body: mockResponse,
|
||||
});
|
||||
|
||||
const result = await getMailDomainAccesses({
|
||||
page: 1,
|
||||
slug: 'example-slug',
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
'/mail-domains/example-slug/accesses/?page=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when the API call fails', async () => {
|
||||
fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', {
|
||||
status: 500,
|
||||
body: { cause: ['Internal server error'] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
getMailDomainAccesses({ page: 1, slug: 'example-slug' }),
|
||||
).rejects.toThrow(APIError);
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMailDomainAccesses', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('fetches and returns the accesses data using the hook', async () => {
|
||||
const mockResponse = {
|
||||
count: 2,
|
||||
results: [
|
||||
mockAccess,
|
||||
{
|
||||
id: '2',
|
||||
role: Role.VIEWER,
|
||||
user: { id: '12', name: 'username2', email: 'user2@test.com' },
|
||||
can_set_role_to: [Role.VIEWER],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', {
|
||||
status: 200,
|
||||
body: mockResponse,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useMailDomainAccesses({ page: 1, slug: 'example-slug' }),
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => result.current.isSuccess);
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(mockResponse));
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
'/mail-domains/example-slug/accesses/?page=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles an API error properly with the hook', async () => {
|
||||
fetchMock.getOnce('end:/mail-domains/example-slug/accesses/?page=1', {
|
||||
status: 500,
|
||||
body: { cause: ['Internal server error'] },
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useMailDomainAccesses({ page: 1, slug: 'example-slug' }),
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => result.current.isError);
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeInstanceOf(APIError));
|
||||
expect(result.current.error?.message).toBe('Failed to get the accesses');
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { Role } from '../../../domains';
|
||||
import { Access } from '../../types';
|
||||
import {
|
||||
updateMailDomainAccess,
|
||||
useUpdateMailDomainAccess,
|
||||
} from '../useUpdateMailDomainAccess';
|
||||
|
||||
const mockAccess: Access = {
|
||||
id: '1-1-1-1-1',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '2-1-1-1-1',
|
||||
name: 'username1',
|
||||
email: 'user1@test.com',
|
||||
},
|
||||
can_set_role_to: [Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
describe('updateMailDomainAccess', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('updates the access role successfully', async () => {
|
||||
const mockResponse = {
|
||||
...mockAccess,
|
||||
role: Role.VIEWER,
|
||||
};
|
||||
|
||||
fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
|
||||
status: 200,
|
||||
body: mockResponse,
|
||||
});
|
||||
|
||||
const result = await updateMailDomainAccess({
|
||||
slug: 'example-slug',
|
||||
accessId: '1-1-1-1-1',
|
||||
role: Role.VIEWER,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
'/mail-domains/example-slug/accesses/1-1-1-1-1/',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when the API call fails', async () => {
|
||||
fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
|
||||
status: 500,
|
||||
body: { cause: ['Internal server error'] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateMailDomainAccess({
|
||||
slug: 'example-slug',
|
||||
accessId: '1-1-1-1-1',
|
||||
role: Role.VIEWER,
|
||||
}),
|
||||
).rejects.toThrow(APIError);
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateMailDomainAccess', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('updates the role and calls onSuccess callback', async () => {
|
||||
const mockResponse = {
|
||||
...mockAccess,
|
||||
role: Role.VIEWER,
|
||||
};
|
||||
|
||||
fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
|
||||
status: 200,
|
||||
body: mockResponse,
|
||||
});
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdateMailDomainAccess({ onSuccess }),
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
result.current.mutate({
|
||||
slug: 'example-slug',
|
||||
accessId: '1-1-1-1-1',
|
||||
role: Role.VIEWER,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
|
||||
await waitFor(() =>
|
||||
expect(onSuccess).toHaveBeenCalledWith(
|
||||
mockResponse, // data
|
||||
{ slug: 'example-slug', accessId: '1-1-1-1-1', role: Role.VIEWER }, // variables
|
||||
undefined, // context
|
||||
),
|
||||
);
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
'/mail-domains/example-slug/accesses/1-1-1-1-1/',
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onError when the API fails', async () => {
|
||||
fetchMock.patchOnce('end:/mail-domains/example-slug/accesses/1-1-1-1-1/', {
|
||||
status: 500,
|
||||
body: { cause: ['Internal server error'] },
|
||||
});
|
||||
|
||||
const onError = jest.fn();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdateMailDomainAccess({ onError }),
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
result.current.mutate({
|
||||
slug: 'example-slug',
|
||||
accessId: '1-1-1-1-1',
|
||||
role: Role.VIEWER,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
|
||||
await waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useMailDomainAccesses';
|
||||
export * from './useUpdateMailDomainAccess';
|
||||
export * from './useDeleteMailDomainAccess';
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import {
|
||||
KEY_LIST_MAIL_DOMAIN,
|
||||
KEY_MAIL_DOMAIN,
|
||||
} from '@/features/mail-domains/domains';
|
||||
|
||||
import { KEY_LIST_MAIL_DOMAIN_ACCESSES } from './useMailDomainAccesses';
|
||||
|
||||
interface DeleteMailDomainAccessProps {
|
||||
slug: string;
|
||||
accessId: string;
|
||||
}
|
||||
|
||||
export const deleteMailDomainAccess = async ({
|
||||
slug,
|
||||
accessId,
|
||||
}: DeleteMailDomainAccessProps): Promise<void> => {
|
||||
const response = await fetchAPI(
|
||||
`mail-domains/${slug}/accesses/${accessId}/`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to delete the access',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseDeleteMailDomainAccessOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
DeleteMailDomainAccessProps
|
||||
>;
|
||||
|
||||
export const useDeleteMailDomainAccess = (
|
||||
options?: UseDeleteMailDomainAccessOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, DeleteMailDomainAccessProps>({
|
||||
mutationFn: deleteMailDomainAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_MAIL_DOMAIN],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Access } from '../types';
|
||||
|
||||
export type MailDomainAccessesAPIParams = {
|
||||
page: number;
|
||||
slug: string;
|
||||
ordering?: string;
|
||||
};
|
||||
|
||||
type AccessesResponse = APIList<Access>;
|
||||
|
||||
export const getMailDomainAccesses = async ({
|
||||
page,
|
||||
slug,
|
||||
ordering,
|
||||
}: MailDomainAccessesAPIParams): Promise<AccessesResponse> => {
|
||||
let url = `mail-domains/${slug}/accesses/?page=${page}`;
|
||||
|
||||
if (ordering) {
|
||||
url += '&ordering=' + ordering;
|
||||
}
|
||||
|
||||
const response = await fetchAPI(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the accesses',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<AccessesResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_MAIL_DOMAIN_ACCESSES = 'mail-domains-accesses';
|
||||
|
||||
export function useMailDomainAccesses(
|
||||
params: MailDomainAccessesAPIParams,
|
||||
queryConfig?: UseQueryOptions<AccessesResponse, APIError, AccessesResponse>,
|
||||
) {
|
||||
return useQuery<AccessesResponse, APIError, AccessesResponse>({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES, params],
|
||||
queryFn: () => getMailDomainAccesses(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { KEY_MAIL_DOMAIN, Role } from '@/features/mail-domains/domains';
|
||||
|
||||
import { Access } from '../types';
|
||||
|
||||
import { KEY_LIST_MAIL_DOMAIN_ACCESSES } from './useMailDomainAccesses';
|
||||
|
||||
interface UpdateMailDomainAccessProps {
|
||||
slug: string;
|
||||
accessId: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export const updateMailDomainAccess = async ({
|
||||
slug,
|
||||
accessId,
|
||||
role,
|
||||
}: UpdateMailDomainAccessProps): Promise<Access> => {
|
||||
const response = await fetchAPI(
|
||||
`mail-domains/${slug}/accesses/${accessId}/`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
role,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to update role', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<Access>;
|
||||
};
|
||||
|
||||
type UseUpdateMailDomainAccess = Partial<Access>;
|
||||
|
||||
type UseUpdateMailDomainAccessOptions = UseMutationOptions<
|
||||
Access,
|
||||
APIError,
|
||||
UseUpdateMailDomainAccess
|
||||
>;
|
||||
|
||||
export const useUpdateMailDomainAccess = (
|
||||
options?: UseUpdateMailDomainAccessOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Access, APIError, UpdateMailDomainAccessProps>({
|
||||
mutationFn: updateMailDomainAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_MAIL_DOMAIN],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 48 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.85735 7.9065L0 3.05123L3.05331 0L41.593 38.5397L38.5418 41.591L28.4121 31.4612C27.3276 31.7198 26.2034 31.8554 25.0584 31.8554C22.0135 31.8554 19.1145 30.896 16.7161 29.2275V33.941H2.11688V25.5986C2.11688 23.3045 3.99392 21.4274 6.28807 21.4274H13.108C14.5262 21.4274 15.7984 22.1991 16.6118 23.3462C18.6369 26.2055 21.7569 27.5528 24.6246 27.6759L21.953 25.0021C20.5035 24.4223 19.2334 23.4421 18.322 22.1574C17.0706 20.4055 15.2144 19.4044 13.2123 19.3627C13.5397 18.8037 14.0819 18.2886 14.7765 17.8256L13.5522 16.6013C12.4281 18.2573 10.5302 19.3418 8.37367 19.3418C4.91158 19.3418 2.11688 16.5471 2.11688 13.085C2.11688 10.9285 3.20139 9.03063 4.85735 7.9065ZM24.1804 15.1915C24.4786 15.1769 24.7727 15.1706 25.0584 15.1706C29.3965 15.1706 35.403 16.7974 36.9046 19.3418C34.9025 19.3835 33.0463 20.3846 31.7949 22.1365C31.7031 22.2679 31.6072 22.3951 31.5071 22.5203L24.1804 15.1915ZM32.9962 24.0094C33.3174 23.634 33.4863 23.3754 33.5051 23.3462C34.1933 22.3659 35.403 21.4274 37.0089 21.4274H43.8288C46.123 21.4274 48 23.3045 48 25.5986V33.941H42.9299L32.9962 24.0094ZM41.7432 19.3418C38.2811 19.3418 35.4864 16.5471 35.4864 13.085C35.4864 9.62294 38.2811 6.82824 41.7432 6.82824C45.2053 6.82824 48 9.62294 48 13.085C48 16.5471 45.2053 19.3418 41.7432 19.3418ZM25.0584 13.085C21.5964 13.085 18.8017 10.2903 18.8017 6.82824C18.8017 3.36615 21.5964 0.571453 25.0584 0.571453C28.5205 0.571453 31.3152 3.36615 31.3152 6.82824C31.3152 10.2903 28.5205 13.085 25.0584 13.085Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
+104
@@ -0,0 +1,104 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, DropButton, IconOptions, Text } from '@/components';
|
||||
|
||||
import { MailDomain, Role } from '../../domains/types';
|
||||
import { Access } from '../types';
|
||||
|
||||
import { ModalDelete } from './ModalDelete';
|
||||
import { ModalRole } from './ModalRole';
|
||||
|
||||
interface AccessActionProps {
|
||||
access: Access;
|
||||
currentRole: Role;
|
||||
mailDomain: MailDomain;
|
||||
}
|
||||
|
||||
export const AccessAction = ({
|
||||
access,
|
||||
currentRole,
|
||||
mailDomain,
|
||||
}: AccessActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
|
||||
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
|
||||
const [isDropOpen, setIsDropOpen] = useState(false);
|
||||
|
||||
if (
|
||||
currentRole === Role.VIEWER ||
|
||||
(access.role === Role.OWNER && currentRole === Role.ADMIN)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropButton
|
||||
button={
|
||||
<IconOptions
|
||||
isOpen={isDropOpen}
|
||||
aria-label={t('Open the access options modal')}
|
||||
/>
|
||||
}
|
||||
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
|
||||
isOpen={isDropOpen}
|
||||
>
|
||||
<Box>
|
||||
{(mailDomain.abilities.put || mailDomain.abilities.patch) && (
|
||||
<Button
|
||||
aria-label={t('Open the modal to update the role of this access')}
|
||||
onClick={() => {
|
||||
setIsModalRoleOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
edit
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Text $theme="primary">{t('Update role')}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{mailDomain.abilities.delete && (
|
||||
<Button
|
||||
aria-label={t('Open the modal to delete this access')}
|
||||
onClick={() => {
|
||||
setIsModalDeleteOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
delete
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Text $theme="primary">{t('Remove from domain')}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</DropButton>
|
||||
{isModalRoleOpen &&
|
||||
(mailDomain.abilities.put || mailDomain.abilities.patch) && (
|
||||
<ModalRole
|
||||
access={access}
|
||||
currentRole={currentRole}
|
||||
onClose={() => setIsModalRoleOpen(false)}
|
||||
slug={mailDomain.slug}
|
||||
/>
|
||||
)}
|
||||
{isModalDeleteOpen && mailDomain.abilities.delete && (
|
||||
<ModalDelete
|
||||
access={access}
|
||||
currentRole={currentRole}
|
||||
onClose={() => setIsModalDeleteOpen(false)}
|
||||
mailDomain={mailDomain}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AccessesGrid } from '@/features/mail-domains/access-management/components/AccessesGrid';
|
||||
|
||||
import { MailDomain, Role } from '../../domains';
|
||||
|
||||
export const AccessesContent = ({
|
||||
mailDomain,
|
||||
currentRole,
|
||||
}: {
|
||||
mailDomain: MailDomain;
|
||||
currentRole: Role;
|
||||
}) => (
|
||||
<>
|
||||
<AccessesGrid mailDomain={mailDomain} currentRole={currentRole} />
|
||||
</>
|
||||
);
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
import { DataGrid, SortModel, usePagination } from '@openfun/cunningham-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import IconUser from '@/assets/icons/icon-user.svg';
|
||||
import { Box, Card, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { MailDomain, Role } from '../../domains';
|
||||
import { useMailDomainAccesses } from '../api';
|
||||
import { PAGE_SIZE } from '../conf';
|
||||
import { Access } from '../types';
|
||||
|
||||
import { AccessAction } from './AccessAction';
|
||||
|
||||
interface AccessesGridProps {
|
||||
mailDomain: MailDomain;
|
||||
currentRole: Role;
|
||||
}
|
||||
|
||||
type SortModelItem = {
|
||||
field: string;
|
||||
sort: 'asc' | 'desc' | null;
|
||||
};
|
||||
|
||||
const defaultOrderingMapping: Record<string, string> = {
|
||||
'user.name': 'user__name',
|
||||
'user.email': 'user__email',
|
||||
localizedRole: 'role',
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the sorting model based on a given mapping.
|
||||
* @param {SortModelItem} sortModel The sorting model item containing field and sort direction.
|
||||
* @param {Record<string, string>} mapping The mapping object to map field names.
|
||||
* @returns {string} The formatted sorting string.
|
||||
* @todo same as team members grid
|
||||
*/
|
||||
function formatSortModel(
|
||||
sortModel: SortModelItem,
|
||||
mapping = defaultOrderingMapping,
|
||||
) {
|
||||
const { field, sort } = sortModel;
|
||||
const orderingField = mapping[field] || field;
|
||||
return sort === 'desc' ? `-${orderingField}` : orderingField;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mailDomain
|
||||
* @param currentRole
|
||||
* @todo same as team members grid
|
||||
*/
|
||||
export const AccessesGrid = ({
|
||||
mailDomain,
|
||||
currentRole,
|
||||
}: AccessesGridProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const pagination = usePagination({
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const [sortModel, setSortModel] = useState<SortModel>([]);
|
||||
const [accesses, setAccesses] = useState<Access[]>([]);
|
||||
const { page, pageSize, setPagesCount } = pagination;
|
||||
|
||||
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
|
||||
|
||||
const { data, isLoading, error } = useMailDomainAccesses({
|
||||
slug: mailDomain.slug,
|
||||
page,
|
||||
ordering,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const localizedRoles = {
|
||||
[Role.ADMIN]: t('Administrator'),
|
||||
[Role.VIEWER]: t('Viewer'),
|
||||
[Role.OWNER]: t('Owner'),
|
||||
};
|
||||
|
||||
/*
|
||||
* Bug occurs from the Cunningham Datagrid component, when applying sorting
|
||||
* on null values. Sanitize empty values to ensure consistent sorting functionality.
|
||||
*/
|
||||
const accesses =
|
||||
data?.results?.map((access) => ({
|
||||
...access,
|
||||
localizedRole: localizedRoles[access.role],
|
||||
user: {
|
||||
...access.user,
|
||||
name: access.user.name,
|
||||
email: access.user.email,
|
||||
},
|
||||
})) || [];
|
||||
|
||||
setAccesses(accesses);
|
||||
}, [data?.results, t, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
|
||||
}, [data?.count, pageSize, setPagesCount]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
$overflow="auto"
|
||||
$css={`
|
||||
& .c__pagination__goto {
|
||||
display: none;
|
||||
}
|
||||
& table th:first-child,
|
||||
& table td:first-child {
|
||||
padding-right: 0;
|
||||
width: 3.5rem;
|
||||
}
|
||||
& table td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
`}
|
||||
aria-label={t('Accesses list card')}
|
||||
>
|
||||
{error && <TextErrors causes={error.cause} />}
|
||||
|
||||
<DataGrid
|
||||
columns={[
|
||||
{
|
||||
id: 'icon-user',
|
||||
renderCell() {
|
||||
return (
|
||||
<Box $direction="row" $align="center">
|
||||
<IconUser
|
||||
aria-label={t('Access icon')}
|
||||
width={20}
|
||||
height={20}
|
||||
color={colorsTokens()['primary-600']}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: t('Names'),
|
||||
field: 'user.name',
|
||||
},
|
||||
{
|
||||
field: 'user.email',
|
||||
headerName: t('Emails'),
|
||||
},
|
||||
{
|
||||
field: 'localizedRole',
|
||||
headerName: t('Roles'),
|
||||
},
|
||||
{
|
||||
id: 'column-actions',
|
||||
renderCell: ({ row }) => (
|
||||
<AccessAction
|
||||
mailDomain={mailDomain}
|
||||
access={row}
|
||||
currentRole={currentRole}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rows={accesses}
|
||||
isLoading={isLoading}
|
||||
pagination={pagination}
|
||||
onSortModelChange={setSortModel}
|
||||
sortModel={sortModel}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { Radio, RadioGroup } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Role } from '../../domains';
|
||||
|
||||
const ORDERED_ROLES = [Role.VIEWER, Role.ADMIN, Role.OWNER];
|
||||
|
||||
interface ChooseRoleProps {
|
||||
availableRoles: Role[];
|
||||
roleAccess: Role;
|
||||
currentRole: Role;
|
||||
disabled: boolean;
|
||||
setRole: (role: Role) => void;
|
||||
}
|
||||
|
||||
export const ChooseRole = ({
|
||||
availableRoles,
|
||||
roleAccess,
|
||||
disabled,
|
||||
currentRole,
|
||||
setRole,
|
||||
}: ChooseRoleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const rolesToDisplay: Role[] = Array.from(
|
||||
new Set([roleAccess, ...availableRoles]),
|
||||
);
|
||||
|
||||
const translations = {
|
||||
[Role.VIEWER]: t('Viewer'),
|
||||
[Role.ADMIN]: t('Administrator'),
|
||||
[Role.OWNER]: t('Owner'),
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup>
|
||||
{ORDERED_ROLES.filter((role) => rolesToDisplay.includes(role)).map(
|
||||
(role) => {
|
||||
let disableRadio = disabled;
|
||||
if (role === Role.OWNER) {
|
||||
disableRadio = disableRadio || currentRole !== Role.OWNER;
|
||||
}
|
||||
|
||||
return (
|
||||
<Radio
|
||||
key={role}
|
||||
label={translations[role]}
|
||||
value={role}
|
||||
name="role"
|
||||
onChange={(evt) => setRole(evt.target.value as Role)}
|
||||
defaultChecked={roleAccess === role}
|
||||
disabled={disableRadio}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Button,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import IconUser from '@/assets/icons/icon-user.svg';
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { MailDomain, Role } from '../../domains';
|
||||
import { useDeleteMailDomainAccess } from '../api';
|
||||
import { useWhoAmI } from '../hooks/useWhoAmI';
|
||||
import { Access } from '../types';
|
||||
|
||||
export interface ModalDeleteProps {
|
||||
access: Access;
|
||||
currentRole: Role;
|
||||
onClose: () => void;
|
||||
mailDomain: MailDomain;
|
||||
}
|
||||
|
||||
export const ModalDelete = ({
|
||||
access,
|
||||
onClose,
|
||||
mailDomain,
|
||||
}: ModalDeleteProps) => {
|
||||
const { toast } = useToastProvider();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
|
||||
const isNotAllowed = isOtherOwner || isLastOwner;
|
||||
|
||||
const {
|
||||
mutate: removeMailDomainAccess,
|
||||
error: errorDeletion,
|
||||
isError: isErrorUpdate,
|
||||
} = useDeleteMailDomainAccess({
|
||||
onSuccess: () => {
|
||||
toast(
|
||||
t('The access has been removed from the domain'),
|
||||
VariantType.SUCCESS,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
|
||||
// If we remove ourselves, we redirect to the home page
|
||||
// because we are no longer part of the domain
|
||||
if (isMyself) {
|
||||
router.push('/');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
leftActions={
|
||||
<Button color="secondary" fullWidth onClick={() => onClose()}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
rightActions={
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
removeMailDomainAccess({
|
||||
slug: mailDomain.slug,
|
||||
accessId: access.id,
|
||||
});
|
||||
}}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{t('Remove from the domain')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<Text $size="h3" $margin="none">
|
||||
{t('Remove this access from the domain')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box aria-label={t('Radio buttons to update the roles')}>
|
||||
<Text>
|
||||
{t(
|
||||
'Are you sure you want to remove this access from the {{domain}} domain?',
|
||||
{ domain: mailDomain.name },
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{isErrorUpdate && (
|
||||
<TextErrors
|
||||
$margin={{ bottom: 'small' }}
|
||||
causes={errorDeletion.cause}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLastOwner || isOtherOwner) && (
|
||||
<Text
|
||||
$theme="warning"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="0.5rem"
|
||||
$margin="tiny"
|
||||
$justify="center"
|
||||
>
|
||||
<span className="material-icons">warning</span>
|
||||
{isLastOwner &&
|
||||
t(
|
||||
'You are the last owner, you cannot be removed from your domain.',
|
||||
)}
|
||||
{isOtherOwner && t('You cannot remove other owner.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
$padding="big"
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$background={colorsTokens()['primary-150']}
|
||||
$theme="primary"
|
||||
>
|
||||
<IconUser width={20} height={20} aria-hidden="true" />
|
||||
<Text>{access.user.name}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Button,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { useUpdateMailDomainAccess } from '@/features/mail-domains/access-management';
|
||||
|
||||
import { Role } from '../../domains';
|
||||
import { Access } from '../types';
|
||||
|
||||
import { ChooseRole } from './ChooseRole';
|
||||
|
||||
interface ModalRoleProps {
|
||||
access: Access;
|
||||
currentRole: Role;
|
||||
onClose: () => void;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export const ModalRole = ({
|
||||
access,
|
||||
currentRole,
|
||||
onClose,
|
||||
slug,
|
||||
}: ModalRoleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [localRole, setLocalRole] = useState(access.role);
|
||||
const { toast } = useToastProvider();
|
||||
const {
|
||||
mutate: updateMailDomainAccess,
|
||||
error: errorUpdate,
|
||||
isError: isErrorUpdate,
|
||||
isPending,
|
||||
} = useUpdateMailDomainAccess({
|
||||
onSuccess: () => {
|
||||
toast(t('The role has been updated'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
leftActions={
|
||||
<Button
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => onClose()}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
rightActions={
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
updateMailDomainAccess({
|
||||
role: localRole,
|
||||
slug,
|
||||
accessId: access.id,
|
||||
});
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Validate')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={t('Update the role')}
|
||||
>
|
||||
<Box aria-label={t('Radio buttons to update the roles')}>
|
||||
{isErrorUpdate && (
|
||||
<TextErrors
|
||||
$margin={{ bottom: 'small' }}
|
||||
causes={errorUpdate.cause}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChooseRole
|
||||
roleAccess={access.role}
|
||||
availableRoles={access.can_set_role_to}
|
||||
currentRole={currentRole}
|
||||
disabled={false}
|
||||
setRole={setLocalRole}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { MailDomain, Role } from '../../../domains';
|
||||
import { Access } from '../../types';
|
||||
import { AccessAction } from '../AccessAction';
|
||||
import { ModalDelete } from '../ModalDelete';
|
||||
import { ModalRole } from '../ModalRole';
|
||||
|
||||
jest.mock('../ModalRole', () => ({
|
||||
ModalRole: jest.fn(() => <div>Mock ModalRole</div>),
|
||||
}));
|
||||
|
||||
jest.mock('../ModalDelete', () => ({
|
||||
ModalDelete: jest.fn(() => <div>Mock ModalDelete</div>),
|
||||
}));
|
||||
|
||||
describe('AccessAction', () => {
|
||||
const mockMailDomain: MailDomain = {
|
||||
id: '1-1-1-1-1',
|
||||
name: 'example.com',
|
||||
slug: 'example-com',
|
||||
status: 'enabled',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockAccess: Access = {
|
||||
id: '2-1-1-1-1',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '11',
|
||||
name: 'username1',
|
||||
email: 'user1@test.com',
|
||||
},
|
||||
can_set_role_to: [Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
const renderAccessAction = (
|
||||
currentRole: Role = Role.ADMIN,
|
||||
access: Access = mockAccess,
|
||||
mailDomain = mockMailDomain,
|
||||
) =>
|
||||
render(
|
||||
<AccessAction
|
||||
access={access}
|
||||
currentRole={currentRole}
|
||||
mailDomain={mailDomain}
|
||||
/>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing for unauthorized roles', () => {
|
||||
renderAccessAction(Role.VIEWER);
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText('Open the access options modal'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
renderAccessAction(Role.ADMIN, { ...mockAccess, role: Role.OWNER });
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText('Open the access options modal'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Update role" button when mailDomain lacks "put" and "patch" abilities', async () => {
|
||||
const mailDomainWithoutUpdate = {
|
||||
...mockMailDomain,
|
||||
abilities: {
|
||||
...mockMailDomain.abilities,
|
||||
put: false,
|
||||
patch: false,
|
||||
},
|
||||
};
|
||||
|
||||
renderAccessAction(Role.ADMIN, mockAccess, mailDomainWithoutUpdate);
|
||||
|
||||
const openButton = screen.getByLabelText('Open the access options modal');
|
||||
await userEvent.click(openButton);
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText(
|
||||
'Open the modal to update the role of this access',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the role update modal with correct props when "Update role" is clicked', async () => {
|
||||
renderAccessAction();
|
||||
|
||||
const openButton = screen.getByLabelText('Open the access options modal');
|
||||
await userEvent.click(openButton);
|
||||
|
||||
const updateRoleButton = screen.getByLabelText(
|
||||
'Open the modal to update the role of this access',
|
||||
);
|
||||
await userEvent.click(updateRoleButton);
|
||||
|
||||
expect(screen.getByText('Mock ModalRole')).toBeInTheDocument();
|
||||
expect(ModalRole).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
access: mockAccess,
|
||||
currentRole: Role.ADMIN,
|
||||
slug: mockMailDomain.slug,
|
||||
onClose: expect.any(Function),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render "Remove from domain" button when mailDomain lacks "delete" ability', async () => {
|
||||
const mailDomainWithoutDelete = {
|
||||
...mockMailDomain,
|
||||
abilities: {
|
||||
...mockMailDomain.abilities,
|
||||
delete: false,
|
||||
},
|
||||
};
|
||||
|
||||
renderAccessAction(Role.ADMIN, mockAccess, mailDomainWithoutDelete);
|
||||
|
||||
const openButton = screen.getByLabelText('Open the access options modal');
|
||||
await userEvent.click(openButton);
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText('Open the modal to delete this access'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the delete modal with correct props when "Remove from domain" is clicked', async () => {
|
||||
renderAccessAction();
|
||||
|
||||
const openButton = screen.getByLabelText('Open the access options modal');
|
||||
await userEvent.click(openButton);
|
||||
|
||||
const removeButton = screen.getByLabelText(
|
||||
'Open the modal to delete this access',
|
||||
);
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
expect(screen.getByText('Mock ModalDelete')).toBeInTheDocument();
|
||||
expect(ModalDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
access: mockAccess,
|
||||
currentRole: Role.ADMIN,
|
||||
mailDomain: mockMailDomain,
|
||||
onClose: expect.any(Function),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles the DropButton', async () => {
|
||||
renderAccessAction();
|
||||
|
||||
const openButton = screen.getByLabelText('Open the access options modal');
|
||||
expect(screen.queryByText('Update role')).toBeNull();
|
||||
|
||||
await userEvent.click(openButton);
|
||||
expect(screen.getByText('Update role')).toBeInTheDocument();
|
||||
|
||||
// Close the dropdown
|
||||
await userEvent.click(openButton);
|
||||
expect(screen.queryByText('Update role')).toBeNull();
|
||||
});
|
||||
});
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { MailDomain, Role } from '../../../domains';
|
||||
import { AccessesContent } from '../AccessesContent';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@/features/mail-domains/access-management/components/AccessesGrid',
|
||||
() => ({
|
||||
AccessesGrid: jest.fn(() => <div>Mock AccessesGrid</div>),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@/features/mail-domains/assets/mail-domains-logo.svg', () => () => (
|
||||
<svg data-testid="mail-domains-logo" />
|
||||
));
|
||||
|
||||
describe('AccessesContent', () => {
|
||||
const mockRouterPush = jest.fn();
|
||||
|
||||
const mockMailDomain: MailDomain = {
|
||||
id: '1-1-1-1-1',
|
||||
name: 'example.com',
|
||||
slug: 'example-com',
|
||||
status: 'enabled',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
};
|
||||
|
||||
const renderAccessesContent = (
|
||||
currentRole: Role = Role.ADMIN,
|
||||
mailDomain: MailDomain = mockMailDomain,
|
||||
) =>
|
||||
render(
|
||||
<AccessesContent currentRole={currentRole} mailDomain={mailDomain} />,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useRouter as jest.Mock).mockReturnValue({
|
||||
push: mockRouterPush,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the accesses grid correctly', () => {
|
||||
renderAccessesContent();
|
||||
|
||||
expect(screen.getByText('Mock AccessesGrid')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { MailDomain, Role } from '../../../domains';
|
||||
import { Access } from '../../types';
|
||||
import { AccessesGrid } from '../AccessesGrid';
|
||||
|
||||
jest.mock(
|
||||
'@/features/mail-domains/access-management/components/AccessAction',
|
||||
() => ({
|
||||
AccessAction: jest.fn(() => <div>Mock AccessAction</div>),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@/assets/icons/icon-user.svg', () => () => (
|
||||
<svg data-testid="icon-user" />
|
||||
));
|
||||
|
||||
const mockMailDomain: MailDomain = {
|
||||
id: '1-1-1-1-1',
|
||||
name: 'example.com',
|
||||
slug: 'example-com',
|
||||
status: 'enabled',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
abilities: {
|
||||
manage_accesses: true,
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockAccess: Access = {
|
||||
id: '2-1-1-1-1',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '3-1-1-1-1',
|
||||
name: 'username1',
|
||||
email: 'user1@test.com',
|
||||
},
|
||||
can_set_role_to: [Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
const mockAccessCreationResponse = {
|
||||
count: 2,
|
||||
results: [
|
||||
mockAccess,
|
||||
{
|
||||
id: '1-1-1-1-2',
|
||||
role: Role.VIEWER,
|
||||
user: { id: '22', name: 'username2', email: 'user2@test.com' },
|
||||
can_set_role_to: [Role.VIEWER],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('AccessesGrid', () => {
|
||||
const renderAccessesGrid = (role: Role = Role.ADMIN) =>
|
||||
render(<AccessesGrid mailDomain={mockMailDomain} currentRole={role} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders the grid with loading state', async () => {
|
||||
fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', {
|
||||
status: 200,
|
||||
body: mockAccessCreationResponse,
|
||||
});
|
||||
|
||||
renderAccessesGrid();
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByText('username1')).toBeInTheDocument();
|
||||
expect(screen.getByText('username2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an error message if the API call fails', async () => {
|
||||
fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', {
|
||||
status: 500,
|
||||
body: { cause: ['Internal server error'] },
|
||||
});
|
||||
|
||||
renderAccessesGrid();
|
||||
|
||||
expect(await screen.findByText('Internal server error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('applies sorting when a column header is clicked', async () => {
|
||||
fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', {
|
||||
status: 200,
|
||||
body: mockAccessCreationResponse,
|
||||
});
|
||||
|
||||
renderAccessesGrid();
|
||||
|
||||
await screen.findByText('username1');
|
||||
|
||||
fetchMock.getOnce(
|
||||
'end:/mail-domains/example-com/accesses/?page=1&ordering=user__name',
|
||||
{
|
||||
status: 200,
|
||||
body: mockAccessCreationResponse,
|
||||
},
|
||||
);
|
||||
|
||||
const nameHeader = screen.getByText('Names');
|
||||
await userEvent.click(nameHeader);
|
||||
|
||||
// First load call, then sorting call
|
||||
await waitFor(() => expect(fetchMock.calls()).toHaveLength(2));
|
||||
});
|
||||
|
||||
it('displays the correct columns and rows in the grid', async () => {
|
||||
fetchMock.getOnce('end:/mail-domains/example-com/accesses/?page=1', {
|
||||
status: 200,
|
||||
body: mockAccessCreationResponse,
|
||||
});
|
||||
|
||||
renderAccessesGrid();
|
||||
|
||||
// Waiting for the rows to render
|
||||
await screen.findByText('Names');
|
||||
|
||||
expect(screen.getByText('Emails')).toBeInTheDocument();
|
||||
expect(screen.getByText('Roles')).toBeInTheDocument();
|
||||
expect(screen.getByText('username1')).toBeInTheDocument();
|
||||
expect(screen.getByText('user1@test.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText('Mock AccessAction')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { Role } from '../../../domains';
|
||||
import { ChooseRole } from '../ChooseRole';
|
||||
|
||||
describe('ChooseRole', () => {
|
||||
const mockSetRole = jest.fn();
|
||||
|
||||
const renderChooseRole = (
|
||||
props: Partial<React.ComponentProps<typeof ChooseRole>> = {},
|
||||
) => {
|
||||
const defaultProps = {
|
||||
availableRoles: [Role.VIEWER, Role.ADMIN],
|
||||
roleAccess: Role.ADMIN,
|
||||
currentRole: Role.ADMIN,
|
||||
disabled: false,
|
||||
setRole: mockSetRole,
|
||||
...props,
|
||||
};
|
||||
|
||||
return render(<ChooseRole {...defaultProps} />, { wrapper: AppWrapper });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders available roles correctly when we are Administrator', () => {
|
||||
renderChooseRole();
|
||||
expect(screen.getByLabelText('Viewer')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Administrator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders available roles correctly when we are owner', () => {
|
||||
renderChooseRole({
|
||||
currentRole: Role.OWNER,
|
||||
roleAccess: Role.OWNER,
|
||||
});
|
||||
expect(screen.getByLabelText('Viewer')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Administrator')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets default role checked correctly', () => {
|
||||
renderChooseRole({ currentRole: Role.ADMIN });
|
||||
|
||||
const adminRadio: HTMLInputElement = screen.getByLabelText('Administrator');
|
||||
const viewerRadio: HTMLInputElement = screen.getByLabelText('Viewer');
|
||||
|
||||
expect(adminRadio).toBeChecked();
|
||||
expect(viewerRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('calls setRole when a new role is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderChooseRole();
|
||||
|
||||
await user.click(screen.getByLabelText('Viewer'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetRole).toHaveBeenCalledWith(Role.VIEWER);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables radio buttons when disabled prop is true', () => {
|
||||
renderChooseRole({ disabled: true });
|
||||
|
||||
const viewerRadio: HTMLInputElement = screen.getByLabelText('Viewer');
|
||||
const adminRadio: HTMLInputElement = screen.getByLabelText('Administrator');
|
||||
|
||||
expect(viewerRadio).toBeDisabled();
|
||||
expect(adminRadio).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables owner radio button if current role is not owner', () => {
|
||||
renderChooseRole({
|
||||
availableRoles: [Role.VIEWER, Role.ADMIN, Role.OWNER],
|
||||
currentRole: Role.ADMIN,
|
||||
});
|
||||
|
||||
const ownerRadio = screen.getByLabelText('Owner');
|
||||
|
||||
expect(ownerRadio).toBeDisabled();
|
||||
});
|
||||
|
||||
it('removes duplicates from availableRoles', () => {
|
||||
renderChooseRole({
|
||||
availableRoles: [Role.VIEWER, Role.ADMIN, Role.VIEWER],
|
||||
currentRole: Role.ADMIN,
|
||||
});
|
||||
|
||||
const radios = screen.getAllByRole('radio');
|
||||
expect(radios.length).toBe(2); // Only two unique roles should be rendered
|
||||
});
|
||||
|
||||
it('renders and checks owner role correctly when currentRole is owner', () => {
|
||||
renderChooseRole({
|
||||
currentRole: Role.OWNER,
|
||||
roleAccess: Role.OWNER,
|
||||
availableRoles: [Role.OWNER, Role.VIEWER, Role.ADMIN],
|
||||
});
|
||||
|
||||
const ownerRadio: HTMLInputElement = screen.getByLabelText('Owner');
|
||||
|
||||
expect(ownerRadio).toBeInTheDocument();
|
||||
expect(ownerRadio).toBeChecked();
|
||||
});
|
||||
|
||||
it('renders no roles if availableRoles is empty', () => {
|
||||
renderChooseRole({
|
||||
availableRoles: [],
|
||||
currentRole: Role.ADMIN,
|
||||
});
|
||||
|
||||
const radios = screen.queryAllByRole('radio');
|
||||
expect(radios.length).toBe(1); // Only the current role should be rendered
|
||||
});
|
||||
|
||||
it.failing('sets aria-checked attribute correctly for selected roles', () => {
|
||||
renderChooseRole({ currentRole: Role.ADMIN });
|
||||
|
||||
const adminRadio = screen.getByLabelText('Administrator');
|
||||
const viewerRadio = screen.getByLabelText('Viewer');
|
||||
|
||||
expect(adminRadio).toHaveAttribute('aria-checked', 'true');
|
||||
expect(viewerRadio).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
});
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Access } from '@/features/mail-domains/access-management';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { MailDomain, Role } from '../../../domains';
|
||||
import { useWhoAmI } from '../../hooks/useWhoAmI';
|
||||
import { ModalDelete, ModalDeleteProps } from '../ModalDelete';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@openfun/cunningham-react', () => ({
|
||||
...jest.requireActual('@openfun/cunningham-react'),
|
||||
useToastProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../hooks/useWhoAmI', () => ({
|
||||
useWhoAmI: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ModalDelete', () => {
|
||||
const mockRouterPush = jest.fn();
|
||||
const mockClose = jest.fn();
|
||||
const mockToast = jest.fn();
|
||||
|
||||
const mockMailDomain: MailDomain = {
|
||||
id: '1-1-1-1-1',
|
||||
name: 'example.com',
|
||||
slug: 'example-com',
|
||||
status: 'enabled',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockAccess: Access = {
|
||||
id: '2-1-1-1-1',
|
||||
user: { id: '3-1-1-1-1', name: 'username1', email: 'user1@test.com' },
|
||||
role: Role.ADMIN,
|
||||
can_set_role_to: [Role.ADMIN, Role.VIEWER],
|
||||
};
|
||||
|
||||
const renderModalDelete = (props: Partial<ModalDeleteProps> = {}) =>
|
||||
render(
|
||||
<ModalDelete
|
||||
access={mockAccess}
|
||||
currentRole={Role.ADMIN}
|
||||
onClose={mockClose}
|
||||
mailDomain={mockMailDomain}
|
||||
{...props}
|
||||
/>,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
(useRouter as jest.Mock).mockReturnValue({
|
||||
push: mockRouterPush,
|
||||
});
|
||||
(useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast });
|
||||
(useWhoAmI as jest.Mock).mockReturnValue({
|
||||
isMyself: false,
|
||||
isLastOwner: false,
|
||||
isOtherOwner: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the modal with the correct content', () => {
|
||||
renderModalDelete();
|
||||
|
||||
expect(
|
||||
screen.getByText('Remove this access from the domain'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Are you sure you want to remove this access from the example.com domain?',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('username1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
renderModalDelete();
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends a delete request when "Remove from the domain" is clicked', async () => {
|
||||
fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', {
|
||||
status: 204,
|
||||
});
|
||||
|
||||
renderModalDelete();
|
||||
|
||||
const removeButton = screen.getByRole('button', {
|
||||
name: 'Remove from the domain',
|
||||
});
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
'/mail-domains/example-com/accesses/2-1-1-1-1/',
|
||||
);
|
||||
});
|
||||
|
||||
it('displays error message when API call fails', async () => {
|
||||
fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', {
|
||||
status: 500,
|
||||
body: { cause: ['Failed to delete access'] },
|
||||
});
|
||||
|
||||
renderModalDelete();
|
||||
|
||||
const removeButton = screen.getByRole('button', {
|
||||
name: 'Remove from the domain',
|
||||
});
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to delete access')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the remove button if the user is the last owner', () => {
|
||||
(useWhoAmI as jest.Mock).mockReturnValue({
|
||||
isMyself: false,
|
||||
isLastOwner: true,
|
||||
isOtherOwner: false,
|
||||
});
|
||||
|
||||
renderModalDelete();
|
||||
|
||||
const removeButton = screen.getByRole('button', {
|
||||
name: 'Remove from the domain',
|
||||
});
|
||||
expect(removeButton).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'You are the last owner, you cannot be removed from your domain.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the remove button if the user is not allowed to remove another owner', () => {
|
||||
(useWhoAmI as jest.Mock).mockReturnValue({
|
||||
isMyself: false,
|
||||
isLastOwner: false,
|
||||
isOtherOwner: true,
|
||||
});
|
||||
|
||||
renderModalDelete();
|
||||
|
||||
const removeButton = screen.getByRole('button', {
|
||||
name: 'Remove from the domain',
|
||||
});
|
||||
expect(removeButton).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText('You cannot remove other owner.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to home page if user removes themselves', async () => {
|
||||
(useWhoAmI as jest.Mock).mockReturnValue({
|
||||
isMyself: true,
|
||||
isLastOwner: false,
|
||||
isOtherOwner: false,
|
||||
});
|
||||
|
||||
fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', {
|
||||
status: 204,
|
||||
});
|
||||
|
||||
renderModalDelete();
|
||||
|
||||
const removeButton = screen.getByRole('button', {
|
||||
name: 'Remove from the domain',
|
||||
});
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success toast and calls onClose after successful deletion', async () => {
|
||||
fetchMock.deleteOnce('end:/mail-domains/example-com/accesses/2-1-1-1-1/', {
|
||||
status: 204,
|
||||
});
|
||||
|
||||
renderModalDelete();
|
||||
|
||||
const removeButton = screen.getByRole('button', {
|
||||
name: 'Remove from the domain',
|
||||
});
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
'The access has been removed from the domain',
|
||||
VariantType.SUCCESS,
|
||||
{ duration: 4000 },
|
||||
);
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { Role } from '../../../domains';
|
||||
import { useWhoAmI } from '../../hooks/useWhoAmI';
|
||||
import { Access } from '../../types';
|
||||
import { ModalRole } from '../ModalRole';
|
||||
|
||||
jest.mock('@openfun/cunningham-react', () => ({
|
||||
...jest.requireActual('@openfun/cunningham-react'),
|
||||
useToastProvider: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../hooks/useWhoAmI');
|
||||
|
||||
describe('ModalRole', () => {
|
||||
const access: Access = {
|
||||
id: '1-1-1-1-1-1',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '2-1-1-1-1-1',
|
||||
name: 'username1',
|
||||
email: 'user1@test.com',
|
||||
},
|
||||
can_set_role_to: [Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
const mockToast = jest.fn();
|
||||
|
||||
const renderModalRole = (
|
||||
isLastOwner = false,
|
||||
isOtherOwner = false,
|
||||
props?: Partial<React.ComponentProps<typeof ModalRole>>,
|
||||
) => {
|
||||
(useToastProvider as jest.Mock).mockReturnValue({ toast: mockToast });
|
||||
(useWhoAmI as jest.Mock).mockReturnValue({
|
||||
isLastOwner,
|
||||
isOtherOwner,
|
||||
});
|
||||
|
||||
return render(
|
||||
<ModalRole
|
||||
access={access}
|
||||
currentRole={props?.currentRole ?? Role.ADMIN}
|
||||
onClose={mockOnClose}
|
||||
slug="domain-slug"
|
||||
/>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders the modal with all elements', () => {
|
||||
renderModalRole();
|
||||
|
||||
expect(screen.getByText('Update the role')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Validate/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText('Radio buttons to update the roles'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the close function when Cancel is clicked', async () => {
|
||||
renderModalRole();
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the role and closes the modal when Validate is clicked', async () => {
|
||||
fetchMock.patch(`end:mail-domains/domain-slug/accesses/1-1-1-1-1-1/`, {
|
||||
status: 200,
|
||||
body: {
|
||||
id: '1-1-1-1-1-1',
|
||||
role: Role.VIEWER,
|
||||
},
|
||||
});
|
||||
|
||||
renderModalRole();
|
||||
|
||||
const validateButton = screen.getByRole('button', { name: /Validate/i });
|
||||
await userEvent.click(validateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
expect(fetchMock.lastCall()?.[0]).toContain(
|
||||
'/mail-domains/domain-slug/accesses/1-1-1-1-1-1/',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
'The role has been updated',
|
||||
VariantType.SUCCESS,
|
||||
{ duration: 4000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error message when update fails', async () => {
|
||||
fetchMock.patch(`end:mail-domains/domain-slug/accesses/1-1-1-1-1-1/`, {
|
||||
status: 400,
|
||||
body: {
|
||||
cause: ['Error updating role'],
|
||||
},
|
||||
});
|
||||
|
||||
renderModalRole();
|
||||
|
||||
const validateButton = screen.getByRole('button', { name: /Validate/i });
|
||||
await userEvent.click(validateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.calls().length).toBe(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Error updating role')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the available roles and ensures no duplicates', () => {
|
||||
renderModalRole();
|
||||
|
||||
const radioButtons = screen.getAllByRole('radio');
|
||||
expect(radioButtons.length).toBe(2); // Only two roles: Viewer and Admin
|
||||
expect(screen.getByLabelText('Administrator')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Viewer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './AccessesContent';
|
||||
export * from './AccessesGrid';
|
||||
export * from './ChooseRole';
|
||||
export * from './ModalRole';
|
||||
export * from './ModalDelete';
|
||||
@@ -0,0 +1 @@
|
||||
export const PAGE_SIZE = 20;
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useAuthStore } from '@/core/auth/useAuthStore';
|
||||
|
||||
import { Role } from '../../../domains';
|
||||
import { useWhoAmI } from '../../hooks/useWhoAmI';
|
||||
import { Access } from '../../types';
|
||||
|
||||
jest.mock('@/core/auth/useAuthStore');
|
||||
|
||||
const mockAccess: Access = {
|
||||
id: '1-1-1-1-1',
|
||||
user: {
|
||||
id: '2-1-1-1-1',
|
||||
name: 'User One',
|
||||
email: 'user1@example.com',
|
||||
},
|
||||
role: Role.ADMIN,
|
||||
can_set_role_to: [Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
describe('useWhoAmI', () => {
|
||||
beforeEach(() => {
|
||||
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||
authenticated: true,
|
||||
userData: {
|
||||
id: '2-1-1-1-1',
|
||||
name: 'Current User',
|
||||
email: 'currentuser@example.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const renderUseWhoAmI = (access: Access) =>
|
||||
renderHook(() => useWhoAmI(access));
|
||||
|
||||
it('identifies if the current user is themselves', () => {
|
||||
const { result } = renderUseWhoAmI(mockAccess);
|
||||
expect(result.current.isMyself).toBeTruthy();
|
||||
});
|
||||
|
||||
it('identifies if the current user is not themselves', () => {
|
||||
const { result } = renderUseWhoAmI({
|
||||
...mockAccess,
|
||||
user: { ...mockAccess.user, id: '2-1-1-1-2' },
|
||||
});
|
||||
expect(result.current.isMyself).toBeFalsy();
|
||||
});
|
||||
|
||||
it('identifies if the current user is the last owner', () => {
|
||||
const accessAsLastOwner = {
|
||||
...mockAccess,
|
||||
role: Role.OWNER,
|
||||
can_set_role_to: [],
|
||||
};
|
||||
const { result } = renderUseWhoAmI(accessAsLastOwner);
|
||||
expect(result.current.isLastOwner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('identifies if the current user is not the last owner', () => {
|
||||
const accessAsNonOwner = { ...mockAccess, role: Role.ADMIN };
|
||||
const { result } = renderUseWhoAmI(accessAsNonOwner);
|
||||
expect(result.current.isLastOwner).toBeFalsy();
|
||||
});
|
||||
|
||||
it('identifies if the current user is another owner', () => {
|
||||
const accessOfOtherOwner = {
|
||||
...mockAccess,
|
||||
role: Role.OWNER,
|
||||
user: { ...mockAccess.user, id: '2-1-1-1-2' },
|
||||
};
|
||||
|
||||
const { result } = renderUseWhoAmI(accessOfOtherOwner);
|
||||
expect(result.current.isOtherOwner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('identifies if the current user is not another owner', () => {
|
||||
const nonOwnerAccess = {
|
||||
...mockAccess,
|
||||
role: Role.ADMIN,
|
||||
user: { ...mockAccess.user, id: '2-1-1-1-2' },
|
||||
};
|
||||
const { result } = renderUseWhoAmI(nonOwnerAccess);
|
||||
expect(result.current.isOtherOwner).toBeFalsy();
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
|
||||
import { Role } from '../../domains/types';
|
||||
import { Access } from '../types';
|
||||
|
||||
export const useWhoAmI = (access: Access) => {
|
||||
const { userData } = useAuthStore();
|
||||
|
||||
const isMyself = userData?.id === access.user.id;
|
||||
const rolesAllowed = access.can_set_role_to;
|
||||
|
||||
const isLastOwner =
|
||||
!rolesAllowed.length && access.role === Role.OWNER && isMyself;
|
||||
|
||||
const isOtherOwner = access.role === Role.OWNER && userData?.id && !isMyself;
|
||||
|
||||
return {
|
||||
isLastOwner,
|
||||
isOtherOwner,
|
||||
isMyself,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
import { User } from '@/core/auth';
|
||||
|
||||
import { Role } from '../domains/types';
|
||||
|
||||
export interface Access {
|
||||
id: UUID;
|
||||
role: Role;
|
||||
user: User;
|
||||
can_set_role_to: Role[];
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './useMailDomains';
|
||||
export * from './useMailDomain';
|
||||
export * from './useCreateMailbox';
|
||||
export * from './useMailboxes';
|
||||
export * from './useCreateMailDomain';
|
||||
@@ -1,143 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Loader,
|
||||
Modal,
|
||||
ModalSize,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Box, StyledLink, Text, TextErrors } from '@/components';
|
||||
import { useCreateMailDomain } from '@/features/mail-domains';
|
||||
|
||||
import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';
|
||||
|
||||
const FORM_ID = 'form-add-mail-domain';
|
||||
|
||||
export const ModalCreateMailDomain = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const createMailDomainValidationSchema = z.object({
|
||||
name: z.string().min(1, t('Example: saint-laurent.fr')),
|
||||
});
|
||||
|
||||
const methods = useForm<{ name: string }>({
|
||||
delayError: 0,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(createMailDomainValidationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: createMailDomain,
|
||||
isPending,
|
||||
error,
|
||||
} = useCreateMailDomain({
|
||||
onSuccess: (mailDomain) => {
|
||||
router.push(`/mail-domains/${mailDomain.slug}`);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmitCallback = () => {
|
||||
void methods.handleSubmit(({ name }, event) => {
|
||||
event?.preventDefault();
|
||||
void createMailDomain(name);
|
||||
})();
|
||||
};
|
||||
|
||||
const causes = error?.cause?.filter((cause) => {
|
||||
const isFound = cause === 'Mail domain with this name already exists.';
|
||||
|
||||
if (isFound) {
|
||||
methods.setError('name', {
|
||||
type: 'manual',
|
||||
message: t(
|
||||
'This mail domain is already used. Please, choose another one.',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return !isFound;
|
||||
});
|
||||
|
||||
if (!methods) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
leftActions={
|
||||
<StyledLink href="/mail-domains">
|
||||
<Button color="secondary" tabIndex={-1}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</StyledLink>
|
||||
}
|
||||
hideCloseButton
|
||||
closeOnClickOutside
|
||||
closeOnEsc
|
||||
onClose={() => router.push('/mail-domains')}
|
||||
rightActions={
|
||||
<Button
|
||||
onClick={onSubmitCallback}
|
||||
disabled={!methods.watch('name') || isPending}
|
||||
>
|
||||
{t('Add the domain')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<>
|
||||
<MailDomainsLogo aria-hidden="true" />
|
||||
<Text as="h3" $textAlign="center">
|
||||
{t('Add your mail domain')}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form action="" id={FORM_ID}>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="name"
|
||||
render={({ fieldState }) => (
|
||||
<Input
|
||||
fullWidth
|
||||
type="text"
|
||||
{...methods.register('name')}
|
||||
aria-invalid={!!fieldState.error}
|
||||
aria-required
|
||||
required
|
||||
autoComplete="off"
|
||||
label={t('Domain name')}
|
||||
state={fieldState.error ? 'error' : 'default'}
|
||||
text={
|
||||
fieldState?.error?.message
|
||||
? fieldState.error.message
|
||||
: t('Example: saint-laurent.fr')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
{!!causes?.length ? <TextErrors causes={causes} /> : null}
|
||||
|
||||
{isPending && (
|
||||
<Box $align="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
)}
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './Panel';
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { ModalAddMailDomain } from '../components';
|
||||
|
||||
const mockPush = jest.fn();
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: jest.fn().mockImplementation(() => ({
|
||||
push: mockPush,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('ModalAddMailDomain', () => {
|
||||
const getElements = () => ({
|
||||
modalElement: screen.getByText('Add a mail domain'),
|
||||
formTag: screen.getByTitle('Mail domain addition form'),
|
||||
inputName: screen.getByLabelText(/Domain name/i),
|
||||
buttonCancel: screen.getByRole('button', { name: /Cancel/i, hidden: true }),
|
||||
buttonSubmit: screen.getByRole('button', {
|
||||
name: /Add the domain/i,
|
||||
hidden: true,
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders all the elements', () => {
|
||||
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
|
||||
|
||||
const { modalElement, formTag, inputName, buttonCancel, buttonSubmit } =
|
||||
getElements();
|
||||
|
||||
expect(modalElement).toBeVisible();
|
||||
expect(formTag).toBeVisible();
|
||||
expect(inputName).toBeVisible();
|
||||
expect(screen.getByText('Example: saint-laurent.fr')).toBeVisible();
|
||||
expect(buttonCancel).toBeVisible();
|
||||
expect(buttonSubmit).toBeVisible();
|
||||
});
|
||||
|
||||
it('should disable submit button when no field is filled', () => {
|
||||
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
|
||||
|
||||
const { buttonSubmit } = getElements();
|
||||
|
||||
expect(buttonSubmit).toBeDisabled();
|
||||
});
|
||||
|
||||
it('displays validation error on empty submit', async () => {
|
||||
fetchMock.mock(`end:mail-domains/`, 201);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
|
||||
|
||||
const { inputName, buttonSubmit } = getElements();
|
||||
|
||||
await user.type(inputName, 'domain.fr');
|
||||
await user.clear(inputName);
|
||||
|
||||
await user.click(buttonSubmit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Example: saint-laurent.fr/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(fetchMock.lastUrl()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('submits the form when validation passes', async () => {
|
||||
fetchMock.mock(`end:mail-domains/`, {
|
||||
status: 201,
|
||||
body: {
|
||||
name: 'domain.fr',
|
||||
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
slug: 'domainfr',
|
||||
status: 'enabled',
|
||||
abilities: {
|
||||
get: true,
|
||||
patch: true,
|
||||
put: true,
|
||||
post: true,
|
||||
delete: true,
|
||||
manage_accesses: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
|
||||
|
||||
const { inputName, buttonSubmit } = getElements();
|
||||
|
||||
await user.type(inputName, 'domain.fr');
|
||||
|
||||
await user.click(buttonSubmit);
|
||||
|
||||
expect(fetchMock.lastUrl()).toContain('/mail-domains/');
|
||||
expect(fetchMock.lastOptions()).toEqual({
|
||||
body: JSON.stringify({
|
||||
name: 'domain.fr',
|
||||
}),
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(`/mail-domains/domainfr`);
|
||||
});
|
||||
|
||||
it('submits the form on key enter press', async () => {
|
||||
fetchMock.mock(`end:mail-domains/`, 201);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
|
||||
|
||||
const { inputName } = getElements();
|
||||
|
||||
await user.type(inputName, 'domain.fr');
|
||||
await user.type(inputName, '{enter}');
|
||||
|
||||
expect(fetchMock.lastUrl()).toContain('/mail-domains/');
|
||||
expect(fetchMock.lastOptions()).toEqual({
|
||||
body: JSON.stringify({
|
||||
name: 'domain.fr',
|
||||
}),
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
});
|
||||
|
||||
it('displays right error message error when maildomain name is already used', async () => {
|
||||
fetchMock.mock(`end:mail-domains/`, {
|
||||
status: 400,
|
||||
body: {
|
||||
name: 'Mail domain with this name already exists.',
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
|
||||
|
||||
const { inputName, buttonSubmit } = getElements();
|
||||
|
||||
await user.type(inputName, 'domain.fr');
|
||||
|
||||
await user.click(buttonSubmit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This mail domain is already used. Please, choose another one./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(inputName).toHaveFocus();
|
||||
|
||||
await user.type(inputName, 'domain2.fr');
|
||||
expect(buttonSubmit).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays right error message error when maildomain slug is already used', async () => {
|
||||
fetchMock.mock(`end:mail-domains/`, {
|
||||
status: 400,
|
||||
body: {
|
||||
name: 'Mail domain with this Slug already exists.',
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
|
||||
|
||||
const { inputName, buttonSubmit } = getElements();
|
||||
|
||||
await user.type(inputName, 'domainfr');
|
||||
|
||||
await user.click(buttonSubmit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This mail domain is already used. Please, choose another one./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(inputName).toHaveFocus();
|
||||
|
||||
await user.type(inputName, 'domain2fr');
|
||||
|
||||
expect(buttonSubmit).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays right error message error when error 500 is received', async () => {
|
||||
fetchMock.mock(`end:mail-domains/`, {
|
||||
status: 500,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModalAddMailDomain />, { wrapper: AppWrapper });
|
||||
|
||||
const { inputName, buttonSubmit } = getElements();
|
||||
|
||||
await user.type(inputName, 'domain.fr');
|
||||
|
||||
await user.click(buttonSubmit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Your request cannot be processed because the server is experiencing an error. If the problem ' +
|
||||
'persists, please contact our support to resolve the issue: suiteterritoriale@anct.gouv.fr',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(inputName).toHaveFocus();
|
||||
expect(buttonSubmit).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useMailDomains';
|
||||
export * from './useMailDomain';
|
||||
export * from './useAddMailDomain';
|
||||
+18
-6
@@ -1,11 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { MailDomain } from '@/features/mail-domains';
|
||||
|
||||
import { MailDomain } from '../types';
|
||||
|
||||
import { KEY_LIST_MAIL_DOMAIN } from './useMailDomains';
|
||||
|
||||
export const createMailDomain = async (name: string): Promise<MailDomain> => {
|
||||
export interface AddMailDomainParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const addMailDomain = async (
|
||||
name: AddMailDomainParams['name'],
|
||||
): Promise<MailDomain> => {
|
||||
const response = await fetchAPI(`mail-domains/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -23,19 +30,24 @@ export const createMailDomain = async (name: string): Promise<MailDomain> => {
|
||||
return response.json() as Promise<MailDomain>;
|
||||
};
|
||||
|
||||
export function useCreateMailDomain({
|
||||
export const useAddMailDomain = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess: (data: MailDomain) => void;
|
||||
}) {
|
||||
onError: (error: APIError) => void;
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<MailDomain, APIError, string>({
|
||||
mutationFn: createMailDomain,
|
||||
mutationFn: addMailDomain,
|
||||
onSuccess: (data) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_MAIL_DOMAIN],
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -25,7 +25,7 @@ export const getMailDomain = async ({
|
||||
return response.json() as Promise<MailDomainResponse>;
|
||||
};
|
||||
|
||||
const KEY_MAIL_DOMAIN = 'mail-domain';
|
||||
export const KEY_MAIL_DOMAIN = 'mail-domain';
|
||||
|
||||
export function useMailDomain(
|
||||
param: MailDomainParams,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user