Compare commits

...

88 Commits

Author SHA1 Message Date
Sabrina Demagny 617000ccf8 🔖(patch) release version 1.4.2
Update all version files and changelog for patch release.
2024-11-12 14:01:39 +01:00
Sabrina Demagny d75605b280 🐛(mail) fix button display on outlook
In confirmation email of mailbox creation,
button "Go to La Messagerie" disappears on Outlook.
We try to fix here this display bug.
2024-11-12 10:45:06 +01:00
Sabrina Demagny 230ed75d8d 🔖(patch) release version 1.4.1
Update all version files and changelog for patch release.
2024-10-23 22:55:56 +02:00
NathanPanchout 5ac3454c5a 🚑️(frontend) fix MailDomainsLayout
The content is not scrollable. We add an auto overflow to solve this problem
2024-10-23 21:44:28 +02:00
Sabrina Demagny bbac34b62a 🔖(minor) release version 1.4.0 (#483)
Update all version files and changelog for minor release.
2024-10-23 19:15:14 +02:00
Sabrina Demagny 38508c272e 📝(CHANGELOG) fix modification declaration
One of unreleased modification declaration was
declared in a wrong place
2024-10-23 18:46:04 +02:00
Sabrina Demagny 10a81f14e6 ✏️(mail) fix typo in mailbox creation email
Whitespace missing in french translation after ':'
2024-10-23 17:56:40 +02:00
Nathan Panchout a08689a64d (frontend) add tabs for mail domain page (#466)
Currently, it is complicated to understand the navigation between mailbox
management and role management for an email domain.
This is why we add tabs with explicit naming
2024-10-23 17:45:42 +02:00
Marie PUPO JEAMMET 30229e11f9 🐛(sentry) fix duplicated sentry errors
errors were sent to sentry twice
2024-10-22 18:03:01 +02:00
Sabrina Demagny d11d1f3bd0 🐛(script) improve and fix release script
Use regex to replace version and add missing
frontend update version.
2024-10-22 00:28:08 +02:00
renovate[bot] 6a22169dae ⬆️(dependencies) update python dependencies 2024-10-21 12:22:49 +02:00
Marie PUPO JEAMMET 6e7a6e9d51 🔖(patch) release version 1.3.1
Update all version files and changelog for patch release.
2024-10-18 14:49:56 +02:00
Marie PUPO JEAMMET 95fb476041 🔖(minor) release version 1.3.0
Update all version files and changelog for minor release.
2024-10-18 11:53:36 +02:00
Sabrina Demagny 6b4ea1a2e7 💄(mail) improve mailbox creation email
Remove useless icons, modify text
and improve displaying.
2024-10-15 13:34:03 +02:00
Sabrina Demagny be55d6ea09 📝(CHANGELOG) fix some modifications declaration
Some unreleased declarations was declared in a wrong place
2024-10-15 12:55:27 +02:00
Nathan Panchout 9c9216bb51 (frontend) show version number in footer (#461)
In order to see the version number pushed into production
2024-10-14 16:54:21 +02:00
Sabrina Demagny 017f52a0dc (api) add RELEASE version on config endpoint
Add release version deployed to config endpoint
in order to display release info in La Régie footer.
2024-10-14 14:57:28 +02:00
Quentin BEY d635c484ae 🛂(backend) do not duplicate user when disabled
When a user is disabled and tries to login, we
don't want the user to be duplicated,
the user should not be able to login.

Fixes https://github.com/numerique-gouv/people/issues/455
2024-10-11 16:19:18 +02:00
Marie PUPO JEAMMET 0e48bc0f90 🛂(backend) match email if no existing user matches the sub
Some OIDC identity providers may provide a random value in the "sub"
field instead of an identifying ID. In this case, it may be a good
idea to fallback to matching the user on its email field.
2024-10-11 15:24:53 +02:00
Sabrina Demagny f243a2423f (backend) manage roles on domain admin view
Allow to manage mail domain roles on mail domain admin interface
2024-10-10 16:09:37 +02:00
Marie PUPO JEAMMET 763eb254a8 📝(dimai) update docs on dimail
update README and docs describing interoperability between people and dimail

Co-authored-by: Laurent Bossavit <216452+Morendil@users.noreply.github.com>
2024-10-09 10:59:07 +02:00
Marie PUPO JEAMMET 9b613e63a9 🔧(sentry) reduce trace
we reduce trace to reduce spamming on sentry
2024-10-08 14:35:40 +02:00
Nathan Panchout 6b7f00651f 🐛(frontend) Bug manage access role (#449)
* 🐛(frontend) update access role

Currently, when you click on modify a role, the roles displayed are not the
correct ones. We always took the role of the connected user.
Additionally, there was a warning and a ban on changing roles if you were the
last owner, but it was decided that it was the backend that was throwing the
error and the action was allowed.

* (frontend) adapt tests

We adapt the tests. Remove the check if you are the last owner and use the new
props inside the relevant tests
2024-10-08 10:02:15 +02:00
renovate[bot] fe249c5b6f ⬆️(dependencies) update python dependencies 2024-10-07 17:56:01 +02:00
Marie PUPO JEAMMET 579657afa4 🐛(dimail) add status code in case of unexpected error
add status code in case of unexpected error
2024-10-04 11:32:49 +02:00
Marie PUPO JEAMMET ce21a7552b 🔖(patch) release version 1.2.1
Update all version files and changelog for patch release.
2024-10-03 16:02:00 +02:00
Jacques ROUSSEL 4a63a26135 🔐(secret) bump secret
Bump secret in order to use a new djangoSecretKey
2024-10-03 12:16:28 +02:00
Marie PUPO JEAMMET 8e4472befc 🐛(helm) fix dimail api url
wrong value in dimail api url was causing prod to not work
2024-10-03 12:16:28 +02:00
daproclaima 315a6ab931 (frontend) add mail domain access management
- access management view is ready to use
get, patch and delete requests once backend
is ready. How to create accesses with post
will come later in a future commit.
- update translations and component tests.
- reduce gap between mail domains feature
logo and mail domain name in top banner
2024-10-02 17:19:25 +02:00
daproclaima 2894b9a999 🚚(frontend) reorganize mail domains feature
- separate feature into domain, mailboxes
as a member management feature is arriving in
the mail domains feature
- pages/mail-domains/[slug].tsx becomes
pages/mail-domains/[slug]/index.tsx
2024-10-02 17:19:25 +02:00
Jacques ROUSSEL aea15292ee 🔧(mail) use new scaleway email gateway
We modify multiples things :
* settings.py in order to manage the new way to send email with the
scaleway gateway
* helm template to manage new mandatory secret
* helm configuration for staging/preprod/production
2024-10-02 17:05:17 +02:00
Marie PUPO JEAMMET de46a50e8d 🔖(minor) release version 1.2.0
Update all version files and changelog for minor release.
2024-09-30 17:38:44 +02:00
Sabrina Demagny e6ed3c3be2 (backend) domain accesses delete API
Allow to delete a access for a domain.
2024-09-30 17:09:27 +02:00
daproclaima f04c8bc6aa 🥅(frontend) catch new errors on mailbox creation
- catch errors related to MAIL_PROVISIONING_API_CREDENTIALS
introduced in commit #ba30b1d3eec73718add6585f30c6b7959cb21850.
Intentionally does not parse the error
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
as it means the user is neither admin or owner of the domain and
should not access the mailbox creation form
- update translations and component tests
2024-09-30 15:31:26 +01:00
Marie PUPO JEAMMET ae05b430db 🚨(pylint) fix linting error introduced by new pylint version
pylint version v3.3.1 added a new error and broke our CI
2024-09-30 13:06:39 +02:00
Marie PUPO JEAMMET c98d173c0a ✏️(makefile) fix typo in makefile, for dimail setup
make boostrap command failed because of wrong naming of
dimail setup command
2024-09-30 13:06:39 +02:00
renovate[bot] dddc281778 ⬆️(dependencies) update python dependencies 2024-09-30 13:06:39 +02:00
Jacques ROUSSEL bb2279b6d5 🐛(argocd) fix job synchronisation issue
Sometime argocd is locked because of immutable fiels on jobs. The
annotation tells argocd to do a replace to avoid this behaviour
2024-09-30 12:51:36 +02:00
Jacques ROUSSEL 5420b20f43 (ci) add helmfile linter
Add a linter to avoid to merge config that breaks argocd sync
2024-09-30 12:51:36 +02:00
Jacques ROUSSEL fbb2accefb 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-09-30 11:27:33 +02:00
renovate[bot] 75008d3e9a ⬆️(dependencies) update js dependencies 2024-09-30 11:11:13 +02:00
Sabrina Demagny c4c3e9de96 (backend) domain accesses create API
Allow to create (POST) a new access for a domain.
Role can be change only to a role available and
depending to the authenticated user.
2024-09-27 17:55:15 +02:00
Sabrina Demagny 00816e097c (backend) domain accesses update API
Allow to update (PUT, PATCH) an access.
Role can be change only to a role available
depending to the authenticated user.
2024-09-27 16:09:15 +02:00
daproclaima 976c955126 ️(frontend) update left nav panel
- the HTML semantic needed improvement:
the menu items were button wrapped in a li
wrapped in link.
- update related tests
2024-09-27 15:29:18 +02:00
Marie PUPO JEAMMET 2967f1bff2 🐛(dimail) fix mail notification upon mailbox creation
Link to the webmail was missing from notification email.
This fixes html for link to correctly appear.
2024-09-27 12:20:56 +02:00
Marie PUPO JEAMMET a15d548ac4 🐛(dimail) remove trailing slash to lighten dimail's calls/logs
Remove trailing slash on mailbox creation request
to remove 307 redirects
2024-09-26 20:53:25 +02:00
Marie PUPO JEAMMET 7c6b3307fa (dimail) fix logger response in unauthorized response
a test was broken because of logger not logging errors
2024-09-26 20:53:25 +02:00
Marie PUPO JEAMMET 5ded297df6 (mailbox) send new mailbox confirmation email
send mailbox information upon creating a new mailbox
2024-09-26 20:53:25 +02:00
daproclaima b69ce001c8 (frontend) allow group members filtering
- add title above the list of members
- allow members filtering  with an input
- updates translations and related tests
- add global useDebounce hook to prevent
spamming API with useless requests on
inputs sending requests on value change
- add component and e2e tests
2024-09-26 18:09:31 +02:00
daproclaima c0cd136618 ♻️(frontend) improve CardCreateTeam component
- remove link wrapping cancel button and
tabIndex=-1 to button
2024-09-26 17:20:58 +02:00
daproclaima ee5a785d43 🥅(frontend) improve add member form error handling
- translate known errors, including already existing
group error, and directly display the other ones
- add component tests
- update translations
- add parseAPIError, a reusable function to
catch errors on the whole frontend app

Closes issue #293

♻️(frontend) improve general error catching

- change parseAPIError to make it reusable on all
requests
- update components depending on it
2024-09-26 17:20:58 +02:00
daproclaima 3d7dfa019c ⚗️(frontend) show username on AccountDropDown
- show username instead of "My account"
- udpate translations and tests
2024-09-26 16:32:18 +02:00
renovate[bot] cc9303bc9c ⬆️(dependencies) update js dependencies 2024-09-24 20:37:13 +02:00
Jacques ROUSSEL 34341e6f74 🔒️(helm) fix secret sync precedence
When new secret is added to backend secret, it's not sync at the
beginning of argocd synchronisation and jobs are blocked. Theses new
annotations fix this issue.
2024-09-23 12:52:04 +02:00
Marie PUPO JEAMMET 01abc66e59 (dimail) allow la regie to request a token for another user
allow la regie to request a token for another dimail user,
to better track who created/modified which ressource.
2024-09-20 17:21:31 +02:00
Marie PUPO JEAMMET 55d7e846d8 ♻️(serializers) move dimail calls to serializers
we move all business logic from model to serializer.
all API calls (direct and from front) will keep on triggering
expected 3rd party calls while admin actions will uniquely trigger
modifications in our database.
2024-09-20 14:20:22 +02:00
Marie PUPO JEAMMET 232ea97d87 (dimail) populate dimail local database for dev use
this commit adds a script and 'make' command to populate dimail database
with basic objects: an admin account, a regie account, a domain
and an owner for this domain.
2024-09-19 15:10:35 +02:00
renovate[bot] a7afa68afa ⬆️(dependencies) update next to v14.2.10 [SECURITY] 2024-09-19 10:06:09 +02:00
renovate[bot] 932ffb3bcb ⬆️(dependencies) update js dependencies 2024-09-19 09:54:40 +02:00
Jacques ROUSSEL d0254ce963 🔧(helm) bump secret
Bump secret to use the email provisionning secret
2024-09-18 19:02:34 +02:00
renovate[bot] cd1dcf11d5 ⬆️(dependencies) update python dependencies 2024-09-17 19:01:23 +02:00
Marie PUPO JEAMMET 59468aaa12 🍱(dimail) embark dimail-api as container
Embark a dimail-api container, automatically fetched from
their repository, to ensure our "bridge" to dimail-api
is up-to-date when developing.
2024-09-17 18:34:39 +02:00
Sabrina Demagny dd8bd2a89b (backend) domain accesses list API
Add an endpoint to list all accesses created for a domain
Return all roles available to set for each access depending to
the authenticated user.
2024-09-17 17:13:46 +02:00
rouja cc86a3bd61 🔧(helm) add marie key to dev sops secret (#394)
Marie's key was accidentally removed in last commit.
Add her key and restore .sops.yaml file, to easily add keys
for local dev secrets decryption/encryption.
2024-09-11 16:59:22 +02:00
Marie PUPO JEAMMET 7f31a2b820 🔖(minor) release version 1.1.0
Update all version files and changelog for minor release.
2024-09-10 17:20:05 +02:00
Jacques ROUSSEL aaca8819b3 🔧(helm) fix mail provisioning env
I forgot to use the new variable on the deployment.
2024-09-10 16:50:14 +02:00
Sabrina Demagny 77cc64a6c7 (release) add python script to prepare release
Change versions, update changelog and create branch and commit
to submit release informations.
Give following instructions to do after.
2024-09-10 15:30:30 +02:00
Marie PUPO JEAMMET aaad48480a (dimail) fix tests still waiting for domain.secret
For unknown reasons, these tests were forgotten and are still
refering to this 'secret' field, removed in last commit.
2024-09-10 15:29:19 +02:00
Marie PUPO JEAMMET 00dafd4b15 ♻️(dimail) separate headers request from mailbox request
I want to separate headers request form mailbox request,
as we were previously catching the same errors twice.
It should be clearer now.
2024-09-10 15:29:19 +02:00
Jacques ROUSSEL 864702d0ee 🔧(helm) add secret for email provisionning
Add email provisionning secret to be abble to provision email
2024-09-09 18:18:00 +02:00
Marie PUPO JEAMMET 29904ef7b6 (tests) update tests to look for dimail secret in settings
Update back-end tests to match 'secret' field being moved to settings.
2024-09-09 18:18:00 +02:00
Marie PUPO JEAMMET ba30b1d3ee 🗃️(models) remove 'secret' field from mailbox model
We remove 'secret' field, as it won't be of use in interactions
between la Régie and dimail. Régie credentials will be stored and used
as project variable.
2024-09-09 18:18:00 +02:00
renovate[bot] 9503b073b6 ⬆️(dependencies) update python dependencies 2024-09-09 15:01:53 +02:00
daproclaima e4aed82ff2 🥅(frontend) improve error catching in forms
- rename CreateMailboxForm into ModalCreateMailbox,
and useCreateMailDomain into useAddMailDomain
- use useAPIError hook in ModalCreateMailbox.tsx and ModalAddMailDomain
- update translations and tests (include removal of e2e test able
to be asserted by component tests)
2024-09-09 12:43:29 +02:00
daproclaima 25898bbb64 🥅(frontend) handle api errors
- add hook to handle api errors.
- add related component tests
2024-09-09 12:43:29 +02:00
daproclaima b5d8e92d1e 🐛(frontend) fix mail domain creation form
- allow to submit form by pressing "Enter" key
- force focus on form when form is submited
but is invalid
- add error 500 handling
- update related e2e tests
2024-09-09 12:43:29 +02:00
Marie PUPO JEAMMET 237d64b4c5 ♻️(dimail) refacto to better handle 500 errors from dimail
simple refacto to catch all 500 errors, including when
asking for new token.
2024-09-09 12:24:48 +02:00
daproclaima 0d157d3f2b ️(frontend) improve keyboard navigation on modals
- create a temporary Modal component to apply it
a function tracking document update and set
modal elements we want to ignore a tabindex=-1.
- add component tests
- temporary fix. Better to apply them on
cunningham directly
2024-09-09 11:58:37 +02:00
daproclaima d291e55a9e 🐛(frontend) fix mail domain addition form
- remove link wrapping cancel button
2024-09-09 11:58:37 +02:00
daproclaima e7aebfe59e 🐛(frontend) keyboard navigation language picker
- add temporary fix to language picker to
ignore select on keyboard navigation. Needs
to be fixed directly in Cunningham Select
- update related e2e test
2024-09-09 11:58:37 +02:00
daproclaima bd6cd59df6 💬(frontend) change add mail domain text
- update translations and tests
2024-09-09 11:41:34 +02:00
daproclaima 874ce18134 💬(frontend) fix group member removal text
- update FR translation
2024-09-09 11:41:34 +02:00
Marie PUPO JEAMMET 4fe74733a5 📈(monitoring) configure sentry dsn
configure sentry dsn to monitor errors in sentry
2024-09-05 16:16:05 +02:00
Marie PUPO JEAMMET 4b47f80cab 🚨(tests) fix obsolete code warnings
- in docker compose, remove obsolete 'version' field
- in django, replace obsolete CheckConstraints 'check' field by 'condition'
2024-09-05 14:57:32 +02:00
Marie PUPO JEAMMET ba631fafb9 🐛(dimail) improve handling of dimail errors on failed mailbox creation
dimail is called twice when creating a mailbox (once for the token,
and once for the post on mailbox endpoint). we want to clarify
the status_codes and messages of each error to inform user and ease debug
2024-09-05 14:57:32 +02:00
renovate[bot] ce15e8a3ed ⬆️(dependencies) update python dependencies 2024-09-05 12:00:53 +02:00
Marie PUPO JEAMMET 55dc342a8b 🔨(demo) add domains creation to demo
Add domains creation to "make demo" rule. Update related test.
2024-09-03 16:11:23 +02:00
renovate[bot] 05c8f636dd ⬆️(dependencies) update js dependencies 2024-09-02 09:41:24 +02:00
167 changed files with 8501 additions and 1361 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "people,secrets"
+3 -3
View File
@@ -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
View File
@@ -0,0 +1,10 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
+107 -1
View File
@@ -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
+19
View File
@@ -88,6 +88,7 @@ bootstrap: \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
.PHONY: bootstrap
@@ -109,6 +110,7 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
@@ -129,6 +131,12 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
reset-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
@$(MAKE) setup-dimail-db
.PHONY: reset-dimail-container
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
@@ -272,6 +280,13 @@ i18n-generate-and-upload: \
crowdin-upload
.PHONY: i18n-generate-and-upload
# -- INTEROPERABILTY
# -- Dimail configuration
dimail-setup-db:
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db
.PHONY: dimail-setup-db
# -- Mail generator
@@ -343,3 +358,7 @@ start-kind: ## Create the kubernetes cluster
tilt-up: ## start tilt - k8s local development
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
release: ## helper for release and deployment
python scripts/release.py
.PHONY: release
+25 -18
View File
@@ -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
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -168,3 +166,11 @@ services:
- "8080:8080"
depends_on:
- kc_postgresql
dimail:
image: registry.mim-libre.fr/dimail/dimail-api:latest
environment:
DIMAIL_MODE: FAKE
DIMAIL_JWT_SECRET: fake_jwt_secret
ports:
- "8001:8000"
+35
View File
@@ -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
+115
View File
@@ -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)
+14
View File
@@ -0,0 +1,14 @@
import subprocess
import sys
def run_command(cmd, msg=None, shell=False, cwd='.', stdout=None):
if msg is None:
msg = f"# Running: {cmd}"
if stdout is not None:
stdout.write(msg + '\n')
if stdout != sys.stdout:
sys.stdout(msg)
subprocess.check_call(cmd, shell=shell, cwd=cwd, stdout=stdout, stderr=stdout)
if stdout is not None:
stdout.flush()
+1 -1
Submodule secrets updated: a31bc360ab...b7ab5f1411
+1 -1
View File
@@ -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)
+51 -19
View File
@@ -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)
+2 -2
View File
@@ -146,11 +146,11 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
constraint=models.CheckConstraint(condition=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
constraint=models.CheckConstraint(condition=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
),
migrations.AlterUniqueTogether(
name='contact',
+2 -2
View File
@@ -124,12 +124,12 @@ class Contact(BaseModel):
unique_together = ("owner", "base")
constraints = [
models.CheckConstraint(
check=~models.Q(base__isnull=False, owner__isnull=True),
condition=~models.Q(base__isnull=False, owner__isnull=True),
name="base_owner_constraint",
violation_error_message="A contact overriding a base contact must be owned.",
),
models.CheckConstraint(
check=~models.Q(base=models.F("id")),
condition=~models.Q(base=models.F("id")),
name="base_not_self",
violation_error_message="A contact cannot be based on itself.",
),
+2 -1
View File
@@ -19,7 +19,8 @@ class AuthorizationServerClient:
- Setting appropriate headers for secure communication as recommended by RFC drafts.
"""
# ruff: noqa: PLR0913
# ruff: noqa: PLR0913 PLR017
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-arguments
def __init__(
self,
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",
}
+6
View File
@@ -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",
),
]
+14
View File
@@ -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
+1
View File
@@ -4,4 +4,5 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 20,
}
@@ -10,12 +10,15 @@ from uuid import uuid4
from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils.text import slugify
from faker import Faker
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailDomainStatusChoices
fake = Faker()
@@ -152,6 +155,35 @@ def create_demo(stdout):
)
queue.flush()
with Timeit(stdout, "Creating domains"):
for _i in range(defaults.NB_OBJECTS["domains"]):
name = fake.domain_name()
slug = slugify(name)
queue.push(
mailbox_models.MailDomain(
name=name,
# slug should be automatic but bulk_create doesn't use save
slug=slug,
status=random.choice(MailDomainStatusChoices.choices)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating accesses to domains"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domain_id,
user_id=random.choice(users_ids),
role=models.RoleChoices.OWNER,
)
)
queue.flush()
class Command(BaseCommand):
"""A management command to create a demo database."""
@@ -10,12 +10,13 @@ import pytest
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
TEST_NB_OBJECTS = {
"users": 5,
"teams": 3,
"max_identities_per_user": 3,
"max_users_per_team": 5,
"domains": 2,
}
pytestmark = pytest.mark.django_db
@@ -27,9 +28,11 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.User.objects.count() == 5
assert models.Team.objects.count() == 3
assert models.TeamAccess.objects.count() >= 3
assert models.User.objects.count() == TEST_NB_OBJECTS["users"]
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
assert mailbox_models.MailDomainAccess.objects.count() == TEST_NB_OBJECTS["domains"]
def test_commands_createsuperuser():
Binary file not shown.
+247 -101
View File
@@ -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,"
+9
View File
@@ -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)
+121 -6
View File
@@ -1,8 +1,14 @@
"""Client serializers for People's mailbox manager app."""
from rest_framework import serializers
import json
from mailbox_manager import models
from rest_framework import exceptions, serializers
from core.api.serializers import UserSerializer
from core.models import User
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
class MailboxSerializer(serializers.ModelSerializer):
@@ -14,6 +20,29 @@ class MailboxSerializer(serializers.ModelSerializer):
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id"]
def create(self, validated_data):
"""
Override create function to fire a request on mailbox creation.
"""
# send new mailbox request to dimail
client = DimailAPIClient()
response = client.send_mailbox_request(
validated_data, self.context["request"].user.sub
)
# fix format to have actual json, and remove uuid
mailbox_data = json.loads(response.content.decode("utf-8").replace("'", '"'))
del mailbox_data["uuid"]
# actually save mailbox on our database
instance = models.Mailbox.objects.create(**validated_data)
# send confirmation email
client.send_new_mailbox_notification(
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
)
return instance
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
@@ -50,7 +79,89 @@ class MailDomainSerializer(serializers.ModelSerializer):
class MailDomainAccessSerializer(serializers.ModelSerializer):
"""Serialize mail domain accesses."""
"""Serialize mail domain access."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
can_set_role_to = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomainAccess
fields = ["id", "user", "role", "can_set_role_to"]
read_only_fields = ["id", "can_set_role_to"]
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_can_set_role_to(self, access):
"""Return roles available to set for the authenticated user"""
return access.get_can_set_role_to(self.context.get("request").user)
def validate(self, attrs):
"""
Check access rights specific to writing (update/create)
"""
request = self.context.get("request")
authenticated_user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_can_set_role_to(authenticated_user)
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to modify role for this user."
)
raise exceptions.PermissionDenied(message)
# Create
else:
# A domain slug has to be set to create a new access
try:
domain_slug = self.context["domain_slug"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a domain slug in kwargs to create a new domain access."
) from exc
try:
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
except models.MailDomainAccess.DoesNotExist as exc:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
) from exc
# Authenticated user must be owner or admin of current domain to set new roles
if access.role not in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
)
# only an owner can set an owner role to another user
if (
role == enums.MailDomainRoleChoices.OWNER
and access.role != enums.MailDomainRoleChoices.OWNER
):
raise exceptions.PermissionDenied(
"Only owners of a domain can assign other users as owners."
)
attrs["user"] = User.objects.get(pk=self.initial_data["user"])
attrs["domain"] = models.MailDomain.objects.get(
slug=self.context["domain_slug"]
)
return attrs
class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
"""Serialize mail domain access for list and retrieve actions."""
class Meta:
model = models.MailDomainAccess
@@ -58,7 +169,11 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
"id",
"user",
"role",
"created_at",
"updated_at",
"can_set_role_to",
]
read_only_fields = [
"id",
"user",
"role",
"can_set_role_to",
]
read_only_fields = ["id"]
+115 -8
View File
@@ -1,11 +1,12 @@
"""API endpoints"""
from rest_framework import filters, mixins, viewsets
from rest_framework import permissions as drf_permissions
from django.db.models import Subquery
from rest_framework import exceptions, filters, mixins, viewsets
from core import models as core_models
from mailbox_manager import models
from mailbox_manager import enums, models
from mailbox_manager.api import permissions, serializers
@@ -54,19 +55,125 @@ class MailDomainViewSet(
# pylint: disable=too-many-ancestors
class MailDomainAccessViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
):
"""
MailDomainAccess viewset.
API ViewSet for all interactions with mail domain accesses.
GET /api/v1.0/mail-domains/<domain_slug>/accesses/:<domain_access_id>
Return list of all domain accesses related to the logged-in user and one
domain access if an id is provided.
POST /api/v1.0/mail-domains/<domain_slug>/accesses/ with expected data:
- user: str
- role: str [owner|admin|viewer]
Return newly created mail domain access
PUT /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
- role: str [owner|admin|viewer]
Return updated domain access
PATCH /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/ with expected data:
- role: str [owner|admin|viewer]
Return partially updated domain access
DELETE /api/v1.0/mail-domains/<domain_slug>/accesses/<domain_access_id>/
Delete targeted domain access
"""
permission_classes = [drf_permissions.IsAuthenticated]
permission_classes = [permissions.MailDomainAccessRolePermission]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "user", "domain", "role"]
ordering_fields = ["role", "user__email", "user__name"]
ordering = ["-created_at"]
queryset = models.MailDomainAccess.objects.all()
queryset = (
models.MailDomainAccess.objects.all()
.select_related("user")
.order_by("-created_at")
)
list_serializer_class = serializers.MailDomainAccessReadOnlySerializer
detail_serializer_class = serializers.MailDomainAccessSerializer
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
context["authenticated_user"] = self.request.user
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
if self.action in {"list", "retrieve"}:
# Determine which role the logged-in user has in the domain
user_role_query = models.MailDomainAccess.objects.filter(
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
).values("role")[:1]
queryset = (
# The logged-in user should be part of a domain to see its accesses
queryset.filter(
domain__accesses__user=self.request.user,
)
# Abilities are computed based on logged-in user's role and
# the user role on each domain access
.annotate(
user_role=Subquery(user_role_query),
)
.select_related("user")
.distinct()
)
return queryset
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != enums.MailDomainRoleChoices.OWNER
):
domain = instance.domain
# Check if the access being updated is the last owner access for the domain
if (
instance.role == enums.MailDomainRoleChoices.OWNER
and domain.accesses.filter(
role=enums.MailDomainRoleChoices.OWNER
).count()
== 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.PermissionDenied({"role": message})
serializer.save()
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
domain = instance.domain
# Check if the access being deleted is the last owner access for the domain
if (
instance.role == enums.MailDomainRoleChoices.OWNER
and domain.accesses.filter(role=enums.MailDomainRoleChoices.OWNER).count()
== 1
):
message = "Cannot delete the last owner access for the domain."
raise exceptions.PermissionDenied({"detail": message})
return super().destroy(request, *args, **kwargs)
class MailBoxViewSet(
-39
View File
@@ -2,14 +2,10 @@
Mailbox manager application factories
"""
import re
from django.utils.text import slugify
import factory.fuzzy
import responses
from faker import Faker
from rest_framework import status
from core import factories as core_factories
from core import models as core_models
@@ -31,7 +27,6 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
name = factory.Faker("domain_name")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
secret = factory.Faker("password")
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -80,37 +75,3 @@ class MailboxFactory(factory.django.DjangoModelFactory):
)
domain = factory.SubFactory(MailDomainEnabledFactory)
secondary_email = factory.Faker("email")
@classmethod
def _create(cls, model_class, *args, use_mock=True, **kwargs):
domain = kwargs["domain"]
if use_mock and isinstance(domain, models.MailDomain):
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(
rf".*/domains/{domain.name}/mailboxes/{kwargs['local_part']}"
),
body=str(
{
"email": f"{kwargs['local_part']}@{domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
result = super()._create(model_class, *args, **kwargs)
else:
result = super()._create(model_class, *args, **kwargs)
return result
@@ -0,0 +1,148 @@
"""Management command creating a dimail-api container, for test purposes."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
import requests
from rest_framework import status
User = get_user_model()
DIMAIL_URL = "http://host.docker.internal:8001"
admin = {"username": "admin", "password": "admin"}
regie = {"username": "la_regie", "password": "password"}
class Command(BaseCommand):
"""
Management command populate local dimail database, to ease dev
"""
help = "Populate local dimail database, for dev purposes."
def handle(self, *args, **options):
"""Handling of the management command."""
if not settings.DEBUG:
raise CommandError(
("This command is meant to run in local dev environment.")
)
# Create a first superuser for dimail-api container. User creation is usually
# protected behind admin rights but dimail allows to create a first user
# when database is empty
self.create_user(
auth=(None, None),
name=admin["username"],
password=admin["password"],
perms=[],
)
# Create Regie user, auth for all remaining requests
# and your own dev
self.create_user(
auth=(admin["username"], admin["password"]),
name=regie["username"],
password=regie["password"],
perms=["new_domain", "create_users", "manage_users"],
)
# we create a dimail user for keycloak+regie user John Doe
# This way, la Régie will be able to make request in the name of
# this user
people_base_user = User.objects.get(name="John Doe")
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
self.create_domain(domain_name)
self.create_allows(people_base_user.sub, domain_name)
self.stdout.write("DONE", ending="\n")
def create_user(
self,
name,
password,
perms=None,
auth=(regie["username"], regie["password"]),
):
"""
Send a request to create a new user.
"""
response = requests.post(
url=f"{DIMAIL_URL}/users/",
json={
"name": name,
"password": password,
"is_admin": name == admin["username"],
"perms": perms or [],
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(self.style.SUCCESS(f"Creating user {name}......... OK"))
else:
self.stdout.write(
self.style.ERROR(
f"Creating user {name} ......... failed: {response.json()['detail']}"
)
)
def create_domain(self, name, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/domains/",
json={
"name": name,
"context_name": "context",
"features": ["webmail", "mailbox", "alias"],
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(f"Creating domain '{name}' ........ OK")
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating domain '{name}' ........ failed: {response.json()['detail']}"
)
)
def create_allows(self, user, domain, auth=(regie["username"], regie["password"])):
"""
Send a request to create a new allows between user and domain.
"""
response = requests.post(
url=f"{DIMAIL_URL}/allows/",
json={
"domain": domain,
"user": user,
},
auth=auth,
timeout=10,
)
if response.status_code == status.HTTP_201_CREATED:
self.stdout.write(
self.style.SUCCESS(
f"Creating permissions for {user} on {domain} ........ OK"
)
)
else:
self.stdout.write(
self.style.ERROR(
f"Creating permissions for {user} on {domain}\
........ failed: {response.json()['detail']}"
)
)
@@ -0,0 +1,17 @@
# Generated by Django 5.1 on 2024-08-30 12:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0012_alter_mailbox_local_part'),
]
operations = [
migrations.RemoveField(
model_name='maildomain',
name='secret',
),
]
+68 -12
View File
@@ -4,14 +4,13 @@ Declare and configure the models for the People additional application : mailbox
from django.conf import settings
from django.core import exceptions, validators
from django.db import models, transaction
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from core.models import BaseModel
from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
from mailbox_manager.utils.dimail import DimailAPIClient
class MailDomain(BaseModel):
@@ -26,7 +25,6 @@ class MailDomain(BaseModel):
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
class Meta:
db_table = "people_mail_domain"
@@ -49,7 +47,6 @@ class MailDomain(BaseModel):
"""
Compute and return abilities for a given user on the domain.
"""
is_owner_or_admin = False
role = None
if user.is_authenticated:
@@ -105,6 +102,65 @@ class MailDomainAccess(BaseModel):
def __str__(self):
return f"Access of user {self.user} on domain {self.domain}."
def get_can_set_role_to(self, user):
"""Return roles available to set"""
if not user.is_authenticated:
return []
roles = list(MailDomainRoleChoices)
authenticated_user_role = None
# get role of authenticated user
if hasattr(self, "user_role"):
authenticated_user_role = self.user_role
else:
try:
authenticated_user_role = user.mail_domain_accesses.get(
domain=self.domain
).role
except (MailDomainAccess.DoesNotExist, IndexError):
return []
# only an owner can set an owner role
if authenticated_user_role != MailDomainRoleChoices.OWNER:
roles.remove(MailDomainRoleChoices.OWNER)
# if the user authenticated is a viewer, they can't modify role
# and only an owner can change role of an owner
if authenticated_user_role == MailDomainRoleChoices.VIEWER or (
authenticated_user_role != MailDomainRoleChoices.OWNER
and self.role == MailDomainRoleChoices.OWNER
):
return []
# we only want to return other roles available to change,
# so we remove the current role of current access.
roles.remove(self.role)
return sorted(roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the domain access.
"""
role = None
if user.is_authenticated:
try:
role = user.mail_domain_accesses.filter(domain=self.domain).get().role
except (MailDomainAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
return {
"get": bool(role),
"patch": is_owner_or_admin,
"put": is_owner_or_admin,
"post": is_owner_or_admin,
"delete": is_owner_or_admin,
}
class Mailbox(BaseModel):
"""Mailboxes for users from mail domain."""
@@ -139,29 +195,29 @@ class Mailbox(BaseModel):
return f"{self.local_part!s}@{self.domain.name:s}"
def clean(self):
"""Mailboxes can be created only on enabled domains, with a set secret."""
"""
Mailboxes can only be created on enabled domains.
Also, mail-provisioning API credentials must be set for dimail to allow auth.
"""
if self.domain.status != MailDomainStatusChoices.ENABLED:
raise exceptions.ValidationError(
"You can create mailbox only for a domain enabled"
)
if not self.domain.secret:
# Won't be able to query user token if MAIL_PROVISIONING_API_CREDENTIALS are not set
if not settings.MAIL_PROVISIONING_API_CREDENTIALS:
raise exceptions.ValidationError(
"Please configure your domain's secret before creating any mailbox."
"Please configure MAIL_PROVISIONING_API_CREDENTIALS before creating any mailbox."
)
def save(self, *args, **kwargs):
"""
Override save function to fire a request on mailbox creation.
Modification is forbidden for now.
"""
self.full_clean()
if self._state.adding:
with transaction.atomic():
client = DimailAPIClient()
client.send_mailbox_request(self)
return super().save(*args, **kwargs)
return super().save(*args, **kwargs)
# Update is not implemented for now
raise NotImplementedError()
@@ -0,0 +1,173 @@
"""
Test for mail domain accesses API endpoints in People's core app : create
"""
import random
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_create_anonymous():
"""Anonymous users should not be allowed to create mail domain accesses."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = APIClient().post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.MailDomainAccess.objects.exists() is False
def test_api_mail_domain__accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create domain accesses for a domain to
which they are not related.
"""
user = core_factories.UserFactory()
other_user = core_factories.UserFactory()
domain = factories.MailDomainFactory()
client = APIClient()
client.force_login(user)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_viewer():
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
other_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_administrator():
"""
Administrators of a domain should be able to create mail domain accesses
except for the "owner" role.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
other_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": enums.MailDomainRoleChoices.OWNER,
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "Only owners of a domain can assign other users as owners."
}
# It should be allowed to create a lower access
for role in [enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.VIEWER]:
other_user = core_factories.UserFactory()
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
new_mail_domain_access = models.MailDomainAccess.objects.filter(
user=other_user
).last()
assert response.json()["id"] == str(new_mail_domain_access.id)
assert response.json()["role"] == role
assert models.MailDomainAccess.objects.filter(domain=mail_domain).count() == 3
def test_api_mail_domain__accesses_create_authenticated_owner():
"""
Owners of a mail domain should be able to create mail domain accesses whatever the role.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.OWNER)]
)
other_user = core_factories.UserFactory()
role = random.choice([role[0] for role in enums.MailDomainRoleChoices.choices])
client = APIClient()
client.force_login(authenticated_user)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert models.MailDomainAccess.objects.filter(user=other_user).count() == 1
new_mail_domain_access = models.MailDomainAccess.objects.filter(
user=other_user
).get()
assert response.json()["id"] == str(new_mail_domain_access.id)
assert response.json()["role"] == role
@@ -0,0 +1,139 @@
"""
Test for mail_domain accesses API endpoints in People's core app : delete
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a mail domain access."""
access = factories.MailDomainAccessFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_viewer():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain in which they are a simple viewer.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
access = factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
def test_api_mail_domain__accesses_delete_administrators():
"""
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
for role in [enums.MailDomainRoleChoices.VIEWER, enums.MailDomainRoleChoices.ADMIN]:
access = factories.MailDomainAccessFactory(domain=mail_domain, role=role)
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners():
"""
An owner should be able to delete the mail domain access of another user including
a owner access.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.OWNER)]
)
for role in [role[0] for role in enums.MailDomainRoleChoices.choices]:
access = factories.MailDomainAccessFactory(domain=mail_domain, role=role)
assert models.MailDomainAccess.objects.count() == 2
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a mail domain
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
access = factories.MailDomainAccessFactory(
domain=mail_domain,
user=authenticated_user,
role=enums.MailDomainRoleChoices.OWNER,
)
factories.MailDomainAccessFactory.create_batch(9)
assert models.MailDomainAccess.objects.count() == 10
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 10
@@ -0,0 +1,260 @@
"""
Test for mail_domain accesses API endpoints in People's core app : list
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_list_anonymous():
"""Anonymous users should not be allowed to list mail_domain accesses."""
mail_domain = factories.MailDomainFactory()
factories.MailDomainAccessFactory.create_batch(2, domain=mail_domain)
response = APIClient().get(f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mail_domain__accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
to which they are not related.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
factories.MailDomainAccessFactory.create_batch(3, domain=mail_domain)
# Accesses for other mail_domains to which the user is related should not be listed either
other_access = factories.MailDomainAccessFactory(user=user)
factories.MailDomainAccessFactory(domain=other_access.domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_mail_domain__accesses_list_for_authenticated_user_related_to_domain():
"""
Authenticated users should be able to list mail_domain accesses for a mail_domain
to which they are related, with a given role.
"""
viewer, administrator, owner = core_factories.UserFactory.create_batch(3)
mail_domain = factories.MailDomainFactory()
owner_access = factories.MailDomainAccessFactory.create(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
admin_access = factories.MailDomainAccessFactory.create(
domain=mail_domain, user=administrator, role=enums.MailDomainRoleChoices.ADMIN
)
viewer_access = models.MailDomainAccess.objects.create(
domain=mail_domain, user=viewer, role=enums.MailDomainRoleChoices.VIEWER
)
admin_expected_data = {
"id": str(admin_access.id),
"user": {
"id": str(administrator.id),
"email": str(administrator.email),
"name": str(administrator.name),
},
"role": str(admin_access.role),
}
viewer_expected_data = {
"id": str(viewer_access.id),
"user": {
"id": str(viewer.id),
"email": str(viewer.email),
"name": str(viewer.name),
},
"role": str(viewer_access.role),
}
owner_expected_data = {
"id": str(owner_access.id),
"user": {
"id": str(owner.id),
"email": str(owner.email),
"name": str(owner.name),
},
"role": str(owner_access.role),
}
# Grant other mail_domain accesses to the user, they should not be listed either
other_access = factories.MailDomainAccessFactory(user=viewer)
factories.MailDomainAccessFactory(domain=other_access.domain)
client = APIClient()
client.force_login(viewer)
# viewer can see accesses but no action is available
admin_expected_data["can_set_role_to"] = []
viewer_expected_data["can_set_role_to"] = []
owner_expected_data["can_set_role_to"] = []
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
client.force_login(administrator)
# administrator can see and give new role but not an OWNER role
admin_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.VIEWER]
viewer_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.ADMIN]
owner_expected_data["can_set_role_to"] = []
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
client.force_login(owner)
# owner can do everything
admin_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.VIEWER,
]
viewer_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.OWNER,
]
owner_expected_data["can_set_role_to"] = [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
expected = sorted(
[admin_expected_data, viewer_expected_data, owner_expected_data],
key=lambda x: x["role"],
)
assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected
def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
django_assert_num_queries,
):
"""
The number of queries should not depend on the amount of fetched accesses.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user) # random role
client = APIClient()
client.force_login(user)
# Only 3 queries are needed to efficiently fetch mail_domain accesses,
# related users :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - distinct from viewset
with django_assert_num_queries(3):
client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
# create 20 new mail_domain accesses
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
# num queries should still be the same
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 21
def test_api_mail_domain__accesses_list_authenticated_ordering():
"""MailDomain accesses can be ordered by "role"."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user)
# create 20 new mail_domain accesses
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=role",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
results = [access["role"] for access in response.json()["results"]]
assert sorted(results) == results
# check results when we change ordering
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=-role",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
results = [access["role"] for access in response.json()["results"]]
assert sorted(results, reverse=True) == results
@pytest.mark.parametrize("ordering_field", ["email", "name"])
def test_api_mail_domain__accesses_list_authenticated_ordering_user(ordering_field):
"""Mail domain accesses can be ordered by user's fields."""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
models.MailDomainAccess.objects.create(domain=mail_domain, user=user)
for _ in range(20):
factories.MailDomainAccessFactory(domain=mail_domain)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=user__{ordering_field}",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 21
def normalize(x):
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
return x.casefold().replace(" ", "")
results = [access["user"][ordering_field] for access in response.json()["results"]]
assert sorted(results, key=normalize) == results
@@ -0,0 +1,118 @@
"""
Test for mail_domain accesses API endpoints in People's core app : retrieve
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a mail_domain access.
"""
access = factories.MailDomainAccessFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a mail_domain access for
a mail_domain to which they are not related.
"""
user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(domain=factories.MailDomainFactory())
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomainAccess matches the given query."}
# Accesses related to another mail_domain should be excluded even if the user is related to it
for other_access in [
factories.MailDomainAccessFactory(),
factories.MailDomainAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{other_access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "No MailDomainAccess matches the given query."
}
def test_api_mail_domain__accesses_retrieve_authenticated_related():
"""
A user who is related to a mail_domain should be allowed to retrieve the
associated mail_domain user accesses.
"""
owner = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
access = factories.MailDomainAccessFactory(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
results = {
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(owner.email),
"name": str(owner.name),
},
"role": str(access.role),
"can_set_role_to": [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
],
}
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
admin = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
).user
client.force_login(admin)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
# admin can't change role of an owner
results["can_set_role_to"] = []
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
viewer = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
).user
client.force_login(viewer)
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/",
)
# viewer can't change anyone's role
results["can_set_role_to"] = []
assert response.status_code == status.HTTP_200_OK
assert response.json() == results
@@ -0,0 +1,295 @@
"""
Test for mail_domain accesses API endpoints in People's core app : update
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_update_anonymous():
"""An anonymous users should not be allowed to update a mail domain access."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
response = APIClient().put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_unrelated():
"""
An authenticated user should not be allowed to update a mail domain access
for a mail_domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_viewer():
"""A viewer of a mail domain should not be allowed to update accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
access = factories.MailDomainAccessFactory(
domain=mail_domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_viewer_themself():
"""A viewer of a mail domain should not be allowed to update its accesses."""
authenticated_user = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(
user=authenticated_user,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_administrator_except_owner():
"""
An administrator of a mail domain should be allowed to update a user
access for this mail domain, as long as they don't try to set the role to owner.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
admin_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
)
viewer_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{admin_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
admin_access.refresh_from_db()
assert admin_access.role == enums.MailDomainRoleChoices.ADMIN
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{admin_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.VIEWER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
admin_access.refresh_from_db()
assert admin_access.role == enums.MailDomainRoleChoices.VIEWER
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{viewer_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
viewer_access.refresh_from_db()
assert viewer_access.role == enums.MailDomainRoleChoices.VIEWER
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{viewer_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_200_OK
viewer_access.refresh_from_db()
assert viewer_access.role == enums.MailDomainRoleChoices.ADMIN
def test_api_mail_domain__accesses_update_administrator_from_owner():
"""
An administrator for a mail domain, should not be allowed to update
the user access of an "owner" for this mail domain.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
owner = core_factories.UserFactory()
owner_access = factories.MailDomainAccessFactory(
domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(authenticated_user)
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{owner_access.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
owner_access.refresh_from_db()
assert owner_access.role == enums.MailDomainRoleChoices.OWNER
def test_api_mail_domain__accesses_update_owner():
"""
An owner of a mail domain should be allowed to update
a user access for this domain.
"""
owner_authenticated = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(owner_authenticated, enums.MailDomainRoleChoices.OWNER)]
)
user_access1 = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN
)
user_access2 = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(owner_authenticated)
# turn admin in viewer
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access1.id!s}/",
data={"role": enums.MailDomainRoleChoices.VIEWER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access1.refresh_from_db()
assert user_access1.role == enums.MailDomainRoleChoices.VIEWER
# turn viewer in owner
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access1.id!s}/",
data={"role": enums.MailDomainRoleChoices.OWNER},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access1.refresh_from_db()
assert user_access1.role == enums.MailDomainRoleChoices.OWNER
# turn viewer in admin
response = client.put(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{user_access2.id!s}/",
data={"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_access2.refresh_from_db()
assert user_access2.role == enums.MailDomainRoleChoices.ADMIN
def test_api_mail_domain__accesses_update_owner_for_owners():
"""
An owner of a mail domain should be allowed to update
an existing owner access for this mail domain.
"""
owner_authenticated = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(owner_authenticated, enums.MailDomainRoleChoices.OWNER)]
)
other_owner_access = factories.MailDomainAccessFactory(
domain=mail_domain, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner_authenticated)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{other_owner_access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_200_OK
other_owner_access.refresh_from_db()
assert other_owner_access.role == new_role
def test_api_mail_domain__accesses_update_owner_self():
"""
An owner of a mail domain should be allowed to update
their own user access provided there are other owners in the mail domain.
"""
owner_authenticated = core_factories.UserFactory()
access = factories.MailDomainAccessFactory(
user=owner_authenticated, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(owner_authenticated)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["role"]
== "Cannot change the role to a non-owner role for the last owner access."
)
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.OWNER
# Add another owner and it should now work
factories.MailDomainAccessFactory(
domain=access.domain, role=enums.MailDomainRoleChoices.OWNER
)
for new_role in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
]:
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
data={"role": new_role},
format="json",
)
assert response.status_code == status.HTTP_200_OK
access.refresh_from_db()
assert access.role == new_role
@@ -4,6 +4,11 @@ Unit tests for the mailbox API
import json
import re
from logging import Logger
from unittest import mock
from django.test.utils import override_settings
from django.utils.translation import gettext_lazy as _
import pytest
import responses
@@ -230,7 +235,8 @@ def test_api_mailboxes__create_administrator_missing_fields():
assert response.json() == {"secondary_email": ["This field is required."]}
### SYNC TO PROVISIONING API
### REACTING TO DIMAIL-API
### We mock dimail's responses to avoid testing dimail's container too
def test_api_mailboxes__unrelated_user_provisioning_api_not_called():
@@ -285,6 +291,56 @@ def test_api_mailboxes__domain_viewer_provisioning_api_not_called():
assert response.status_code == status.HTTP_403_FORBIDDEN
@mock.patch.object(Logger, "error")
def test_api_mailboxes__async_dimail_unauthorized(mock_error):
"""
Dimail should raise an error if token has been successfully granted
but mailbox creation request returns a 403.
i.e. user exists on dimail-api but has no permission on that domain
"""
# creating all needed objects
# this access somehow exists solely in our database but not in dimail
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
mailbox_data = serializers.MailboxSerializer(
factories.MailboxFactory.build(domain=access.domain)
).data
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
status=status.HTTP_200_OK, # user is in dimail-api
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(
rf".*/domains/{access.domain.name}/mailboxes/{mailbox_data['local_part']}"
),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert mock_error.call_count == 1
assert mock_error.call_args_list[0][0] == (
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
access.domain.name,
)
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.OWNER],
@@ -295,7 +351,7 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
"""
Domain owner/admin should be able to create mailboxes.
Provisioning API should be called when owner/admin makes a call.
Expected response contains new email and password.
Succesfull 201 response from dimail should trigger mailbox creation on our side.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=role)
@@ -359,3 +415,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",
}
)
}
}
)
+97 -36
View File
@@ -1,9 +1,13 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
import smtplib
from logging import getLogger
from django.conf import settings
from django.core import exceptions
from django.contrib.sites.models import Site
from django.core import exceptions, mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
import requests
from rest_framework import status
@@ -29,43 +33,59 @@ class DimailAPIClient:
"""A dimail-API client."""
API_URL = settings.MAIL_PROVISIONING_API_URL
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
def get_headers(self, domain):
"""Build header dict from domain object."""
# self.secret is the encoded basic auth, to request a new token from dimail-api
def get_headers(self, user_sub=None):
"""
Build headers dictionary. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
to get a token from dimail /token/ endpoint.
If provided, request user' sub is used for la regie to log in as this user,
thus allowing for more precise logs.
"""
headers = {"Content-Type": "application/json"}
params = None
if user_sub:
params = {"username": str(user_sub)}
response = requests.get(
f"{self.API_URL}/token/",
headers={"Authorization": f"Basic {domain.secret}"},
timeout=status.HTTP_200_OK,
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
params=params,
timeout=20,
)
if response.json() == "{'detail': 'Permission denied'}":
raise exceptions.PermissionDenied(
"This secret does not allow for a new token."
)
if "access_token" in response.json():
if response.status_code == status.HTTP_200_OK:
headers["Authorization"] = f"Bearer {response.json()['access_token']}"
logger.info("Token succesfully granted by mail-provisioning API.")
return headers
return headers
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: Could not retrieve a token,\
please check 'MAIL_PROVISIONING_API_CREDENTIALS' setting.",
)
raise exceptions.PermissionDenied(
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
def send_mailbox_request(self, mailbox):
return self.pass_dimail_unexpected_response(response)
def send_mailbox_request(self, mailbox, user_sub=None):
"""Send a CREATE mailbox request to mail provisioning API."""
payload = {
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
"givenName": mailbox["first_name"],
"surName": mailbox["last_name"],
"displayName": f"{mailbox['first_name']} {mailbox['last_name']}",
}
headers = self.get_headers(user_sub)
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox.domain}/mailboxes/{mailbox.local_part}/",
f"{self.API_URL}/domains/{mailbox['domain']}/mailboxes/{mailbox['local_part']}",
json=payload,
headers=self.get_headers(mailbox.domain),
headers=headers,
verify=True,
timeout=10,
)
@@ -78,27 +98,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,
)
+34 -3
View File
@@ -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
View File
@@ -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",
]
+5
View File
@@ -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,
+11 -10
View File
@@ -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;
};
+1
View File
@@ -3,3 +3,4 @@ export * from './conf';
export * from './fetchApi';
export * from './types';
export * from './utils';
export * from './hooks';
@@ -0,0 +1,106 @@
import { APIError } from '@/api/index';
type ErrorCallback = () => void;
// Type for the error tuple format [causes, message, handleError]
type ErrorTuple = [string[], string, ErrorCallback | undefined];
// Server error tuple [defaultMessage, handleError]
type ServerErrorTuple = [string, ErrorCallback | undefined];
/**
* @function parseAPIError
* @description function to centralize APIError handling to treat discovered errors
* and error type 500 with default behavior using a simplified tuple structure.
* @param error - APIError object
* @param errorParams - Array of tuples: each contains an array of causes, a message, and an optional callback function.
* @param serverErrorParams - A tuple for server error handling: [defaultMessage, handleError]
* @returns Array of error messages or undefined
*/
export const parseAPIError = ({
error,
errorParams,
serverErrorParams,
}: {
error: APIError | null;
errorParams?: ErrorTuple[];
serverErrorParams?: ServerErrorTuple;
}): string[] | undefined => {
if (!error) {
return;
}
// Parse known error causes using the tuple structure
const errorCauses =
error.cause?.length && errorParams
? parseAPIErrorCause(error, errorParams)
: undefined;
// Check if it's a server error (500) and handle that case
const serverErrorCause =
(error?.status === 500 || !error?.status) && serverErrorParams
? parseServerAPIError(serverErrorParams)
: undefined;
// Combine the causes and return
const causes: string[] = errorCauses ? [...errorCauses] : [];
if (serverErrorCause) {
causes.unshift(serverErrorCause);
}
return causes.length ? causes : undefined;
};
/**
* @function parseAPIErrorCause
* @description Processes known API error causes using the tuple structure.
* @param error - APIError object
* @param errorParams - Array of tuples: each contains an array of causes, a message, and an optional callback function.
* @returns Array of error messages
*/
export const parseAPIErrorCause = (
error: APIError,
errorParams: ErrorTuple[],
): string[] | undefined => {
if (!error.cause) {
return;
}
return error.cause.reduce((causes: string[], cause: string) => {
// Find the matching error tuple
const matchedError = errorParams.find(([errorCauses]) =>
errorCauses.some((knownCause) => new RegExp(knownCause, 'i').test(cause)),
);
if (matchedError) {
const [, message, handleError] = matchedError;
causes.push(message);
if (handleError) {
handleError();
}
} else {
// If no match is found, add the original cause
causes.push(cause);
}
return causes;
}, []);
};
/**
* @function parseServerAPIError
* @description Handles server errors (500) and adds the default message.
* @param serverErrorParams - Tuple [defaultMessage, handleError]
* @returns Server error message
*/
export const parseServerAPIError = ([
defaultMessage,
handleError,
]: ServerErrorTuple): string => {
if (handleError) {
handleError();
}
return defaultMessage;
};
@@ -0,0 +1,42 @@
import {
Modal as CunninghamModal,
ModalProps,
} from '@openfun/cunningham-react';
import React, { useEffect } from 'react';
// Define a wrapper component that extends ModalProps to accept the same props as the Modal
export const Modal: React.FC<ModalProps> = ({ children, ...props }) => {
// Apply the hook here once for all modals
usePreventFocusVisible(['.c__modal__content']);
return <CunninghamModal {...props}>{children}</CunninghamModal>;
};
/**
* @description used to prevent elements to be navigable by keyboard when only a DOM mutation causes the elements to be
* in the document
* @see https://github.com/numerique-gouv/people/pull/379
*/
export const usePreventFocusVisible = (elements: string[]) => {
useEffect(() => {
const observer = new MutationObserver((mutationsList) => {
mutationsList.forEach(() => {
elements.forEach((selector) =>
document.querySelector(selector)?.setAttribute('tabindex', '-1'),
);
observer.disconnect();
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [elements]);
return null;
};
@@ -0,0 +1,80 @@
import { ModalSize } from '@openfun/cunningham-react';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { Modal, usePreventFocusVisible } from '../Modal';
describe('usePreventFocusVisible hook', () => {
const TestComponent = () => {
usePreventFocusVisible(['.test-element']);
return (
<div>
<div className="test-element">Test Element</div>
</div>
);
};
const originalMutationObserver = global.MutationObserver;
const mockDisconnect = jest.fn();
const mutationObserverMock = jest.fn(function MutationObserver(
callback: MutationCallback,
) {
this.observe = () => {
callback([{ type: 'childList' }] as MutationRecord[], this);
};
this.disconnect = mockDisconnect;
});
afterEach(() => jest.clearAllMocks());
beforeAll(
() =>
(global.MutationObserver =
mutationObserverMock as unknown as typeof MutationObserver),
);
afterAll(() => (global.MutationObserver = originalMutationObserver));
test('sets tabindex to -1 on the target elements', () => {
const { unmount } = render(<TestComponent />);
const targetElement = screen.getByText('Test Element');
expect(targetElement).toHaveAttribute('tabindex', '-1');
unmount();
expect(mockDisconnect).toHaveBeenCalled();
});
});
describe('Modal', () => {
test('applies usePreventFocusVisible and sets tabindex', async () => {
render(
<Modal
isOpen={true}
onClose={() => {}}
size={ModalSize.MEDIUM}
title={<h3>Test Modal Title</h3>}
leftActions={<button>Cancel</button>}
rightActions={<button>Submit</button>}
>
<p>Modal content</p>
</Modal>,
{ wrapper: AppWrapper },
);
/* eslint-disable testing-library/no-node-access */
const modalContent = document.querySelector('.c__modal__content');
/* eslint-enable testing-library/no-node-access */
await waitFor(() => {
expect(modalContent).toHaveAttribute('tabindex', '-1');
});
});
});
@@ -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 })} &nbsp;
</>
)}
<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) => {
@@ -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');
});
});
@@ -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());
});
});
@@ -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);
});
});
@@ -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';
@@ -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);
}
},
});
};
@@ -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,
});
}
@@ -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);
}
},
});
};
@@ -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

@@ -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}
/>
)}
</>
);
};
@@ -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} />
</>
);
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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();
});
});
@@ -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();
});
});
@@ -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);
});
});
@@ -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');
});
});
@@ -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();
});
});
@@ -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;
@@ -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();
});
});
@@ -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';
@@ -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';
@@ -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);
},
});
}
};
@@ -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