Compare commits

...

54 Commits

Author SHA1 Message Date
Marie PUPO JEAMMET 7bbbec21f2 fixup! 📝(documentation) document webhooks and matrix interop 2025-06-30 12:02:38 +02:00
Marie PUPO JEAMMET 3ac28e4b0d 📝(documentation) document webhooks and matrix interop
Briefly document matrix interop and webhooks basic requirements
2025-06-30 11:47:35 +02:00
Marie PUPO JEAMMET 10dcdfc8c2 🐛(webhook) fix error raised when no secret
Our Tchap webhook contains no secret (as a dedicated access token is provided)
This lead to an error upon trying to join rooms.
2025-06-23 17:28:41 +02:00
elvoisin f30398fbc7 🐛(front) fix button add mail domain (#932)
fix button rules + fix bad wording
2025-06-23 16:27:52 +02:00
Marie PUPO JEAMMET cc39ed5298 (teams) add matrix webhook for teams
A webhook to invite/kick team members to a matrix room.
2025-06-21 00:15:16 +02:00
Marie PUPO JEAMMET 7bebf13d88 🐛(domains) reduce logs around domain invitations
reduce logs and add tests around domain invitations
2025-06-21 00:15:16 +02:00
Quentin BEY e64a34f167 🧑‍💻(keycloak) command to add new client
This introduce a command to create a new client into the "people" realm.
This could be use to create a specific client to test the resource
server mode on a local deployment:
 - run the people stack
 - add the new client, let say, for docs
 - configure the people backend for token introspection
 - make calls from docs backend to people's backend

The new client is not mandatory because the same client could be used
everywhere but this would not demonstrate the fact the introspection
works in a real world configuration.
2025-06-21 00:15:16 +02:00
Quentin BEY 3379d6d499 🔧(git) set LF line endings for all text files
Windows users are by default using CRLF line endings,
which can cause issues with some tools and
environments. This commit sets the `.gitattributes`
file to enforce LF line endings for all text
files in the repository.

Based on the same commit on docs
2025-06-21 00:15:16 +02:00
Quentin BEY 213656fc2e 🧑‍💻(docker) split frontend to another file
This commit aims at improving the user experience:
- Use a dedicated `Dockerfile` for the frontend
- Run the backend and frontend in "watch"/dev mode in Docker
- Do not start all Docker instances for small tasks
2025-06-21 00:15:16 +02:00
Quentin BEY 4dfd682cb6 (resource-server) add SCIM /Me endpoint
This provide a "self-care" SCIM endpoint, authenticated with OIDC token
introspection. This endpoint will be use by services to fetch the user's
team list.

We chose to use the SCIM format (even if this is not a SCIM context) to
make it easier to understand/maintain/plug.
2025-06-21 00:15:16 +02:00
mjeammet 160c45bf35 🌐(i18n) update translated strings
Update translated files with new translations
2025-06-11 11:40:56 +02:00
Marie PUPO JEAMMET 3fdd8a230c 🔖(minor) release version 1.17.0
Update all version files and changelog for minor release.
2025-06-11 11:40:56 +02:00
renovate[bot] b47246826e ⬆️(dependencies) manually update python dependencies
Many of our dependencies still aren't compatible with Redis 6.
This results in a partial upgrade of our dependencies.
2025-06-10 21:59:41 +02:00
Marie PUPO JEAMMET 5429354261 🐛(config) whitelist kube pod
Whitelist our pod's IP address. Based on Visio's PR
https://github.com/suitenumerique/meet/pull/95
2025-06-10 18:12:02 +02:00
Marie PUPO JEAMMET 86c98cc426 🧑‍💻(dimail) modify makefile to setup dimail container upon running demo
Setup dimail container upon running demo so that it's always in sync.
2025-06-10 16:52:04 +02:00
Marie PUPO JEAMMET 0bbce9ffc8 🔥(dimail) remove obsolete user and allow creation in dimail setup
Remove obsolete duplication to dimail database.
See PR https://github.com/suitenumerique/people/pull/886 for more context.
2025-06-10 16:52:04 +02:00
Marie PUPO JEAMMET ce66645294 🐛(demo) fix domains names
Mailbox creation was broken in dev because of wrong format of domains names
2025-06-10 16:52:04 +02:00
Eléonore Voisin 485eb88dd1 (frontend) add crisp script
add crisp chatbox to global layout
2025-06-10 16:13:24 +02:00
renovate[bot] a8b08c4b6d ⬆️(dependencies) update requests to v2.32.4 [SECURITY] 2025-06-10 15:47:00 +02:00
renovate[bot] 23f5a13ccc ⬆️(dependencies) update django to v5.2.2 [SECURITY] 2025-06-10 12:20:27 +00:00
elvoisin 1245c54c61 ️(fix) add error when mailbox create failed (#915)
fix toast error when mailbox create failed
2025-06-10 12:19:31 +00:00
Quentin BEY 95f63fa56d 🔒️(frontend) hide Nginx server version in error responses
Remove version disclosure in /assets/ error pages identified by security
auditor to prevent information leakage vulnerability.
2025-06-05 19:27:24 +02:00
Marie PUPO JEAMMET 4c3891047b (mailboxes) add a button to reset password on enabled mailboxes
Domain admins can now send requests to reset password for any mailbox
on their domains.
2025-05-20 16:45:14 +02:00
Marie PUPO JEAMMET fce9b1e490 🐛(dimail) fix broken auth while resetting passwords
Dimail client's "reset password" method was using basic auth while
dimail expects a token for this endpoint. Fixed it.
2025-05-20 16:45:14 +02:00
Marie PUPO JEAMMET 83bec33bdb ✏️(typo) fix typos
Did you know ? "Successful" actually takes two esses.
2025-05-20 16:45:14 +02:00
Quentin BEY 8fed2606d6 🧑‍💻(frontend) add makefile lint --fix
Add a Makefile command to easily run the automatic fixup for frontend
files.
2025-05-20 15:00:14 +02:00
Marie PUPO JEAMMET c7eb86eaa9 ⬆️(dependencies) manually upgrade python dependencies
Renovate seems to be in a weird place dealing with conflicting versions.
This PR handles the non-problematic upgrades
2025-05-16 19:32:15 +02:00
qbey be1513e106 🌐(i18n) update translated strings
Update translated files with new translations
2025-05-16 11:22:18 +00:00
Quentin BEY 8e85f303ec 🌐(frontend) update some translated messages
Slight cleanup of translations.
2025-05-16 11:55:32 +02:00
Quentin BEY ee564ff6ba 🎨(front) fix mail domain list display
The first implementation was using `div` instead of the proper
components.
2025-05-16 11:55:32 +02:00
renovate[bot] b0355059b7 ⬆️(dependencies) update js dependencies 2025-05-16 11:41:03 +02:00
Marie PUPO JEAMMET 6e792986be (admin) send pending mailboxes from admin
Provides an admin action to send all pending mailboxes for an active domain.
This allows quick fixes when mailboxes fell out of sync.
2025-05-16 11:30:46 +02:00
Quentin BEY fe9fb67fed 🔒️(docker) patch libxml to address CVE
Trivy scan detects some issue:
┌─────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬───────────────────────────────────────────────────────────┐
│ Library │ Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                           Title                           │
├─────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼───────────────────────────────────────────────────────────┤
│ libxml2 │ CVE-2025-32414 │ HIGH     │ fixed  │ 2.13.4-r5         │ 2.13.4-r6     │ libxml2: Out-of-Bounds Read in libxml2                    │
│         │                │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2025-32414                │
│         ├────────────────┤          │        │                   │               ├───────────────────────────────────────────────────────────┤
│         │ CVE-2025-32415 │          │        │                   │               │ libxml2: Out-of-bounds Read in xmlSchemaIDCFillNodeTables │
│         │                │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2025-32415                │
└─────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴───────────────────────────────────────────────────────────┘
2025-05-16 10:03:52 +02:00
Quentin BEY 91fbef9066 🎨(front) rewrite the team main page
The first rewrite I made was using `div` instead of the proper
components.
2025-05-14 18:46:49 +02:00
Quentin BEY 4f3c9abe62 🐛(front) improve domain "return" button
The button to return to domain list was reloading the whole page which
was quite long (and somehow failing on staging).
2025-05-14 17:57:29 +02:00
Quentin BEY bd43e4620d 💄(teams) update team list page to match new UI
This is an attempt to quick fix the team page to match the new UI.
2025-05-14 17:57:29 +02:00
Quentin BEY 8c67d4a004 (e2e) add mailboxe (dis/en)able check
This provides a new test to check the action on the mailbox item.
For now we can only enable or disable a mailbox.

We need to create the mailbox in the test, so it exists on Dimail side.
2025-05-14 17:46:26 +02:00
Quentin BEY 78cb3e693c 💚(e2e) remove useless test on "tabs"
This is supposed to be a validation test on accessibility but in fact
it's just a flaky test which does not provide any information. We need
to replace this with something smarter.
2025-05-14 17:46:26 +02:00
Quentin BEY cec8e87edd 🐛(front-mail) dynamically display org name
The organization is not always DINUM.
2025-05-14 17:46:26 +02:00
Quentin BEY 35a700d522 💄(cunningham) fix style for display
Some CSS were not found (like color gold-500).
2025-05-14 17:46:26 +02:00
Quentin BEY c786ddbb82 🐛(frontend) re-enable mailbox actions
This code was commented but seems to work properly.
2025-05-14 17:46:26 +02:00
Quentin BEY cb198a9d04 💩(frontend) restore user name / org header
This was added previously and while the organization is not displayed
elsewhere it's better to keep the information displayed somewhere.
2025-05-14 17:43:37 +02:00
Quentin BEY 3d9645b561 ⚰️(i18n) utils module is not used anymore
The use of this module has been removed during the UI refacto.
2025-05-14 17:43:37 +02:00
Quentin BEY 21cbeded18 (frontend) fix end-to-end tests after refacto
Some test were broken other were flaky: fix them.
All tests are not worthy but since it was easy to fix, we keep them
until we write better tests...
2025-05-14 17:43:37 +02:00
Quentin BEY f0c609ef0b 🐛(frontend) fix create team button
The button to create a new team was not displayed properly anymore.
2025-05-14 17:43:37 +02:00
Quentin BEY aa3d90b686 🐛(frontend) fix browser language detection
The `Default language` E2E test detected the browser language was not
automatically detected when user does not have any cookie, it was always
falling back on the defaut language (en).
2025-05-14 17:43:37 +02:00
Quentin BEY 4a08a9ec92 ⚰️(front) remove unused code after UI v2
The linter was unhappy, so I removed the unused variables or code.
2025-05-14 17:43:37 +02:00
Quentin BEY 5544a40f5f 🎨(front) fix linter issues with --fix
Simply run the lint command with "autofix" to format files.
2025-05-14 17:43:37 +02:00
Eléonore Voisin e274c309cd 🎨(frontend) global UI rewrite to match new design
This commit is the result of several squashed commits which were
complicated to disjoin.
This rewrites the base UI, and the mail management interfaces.
2025-05-14 17:41:30 +02:00
renovate[bot] 560998083d ⬆️(dependencies) update django to v5.2.1 [SECURITY] 2025-05-08 18:32:32 +02:00
Marie PUPO JEAMMET 2d56c57102 (dimail) add reset password method
allow domain owner and admins to reset password for a mailbox
they manage. The request is sent to dimail, which responds with
a new randomly generated password. This new password is sent to
secondary email.
2025-05-05 17:28:00 +02:00
Benoit Serrano b5d86967ff 🐛(docker) fix dockerize no matching manifest for linux/arm64/v8 error
fixing docker config for linux/arm64/v8 error
2025-05-05 17:28:00 +02:00
Marie PUPO JEAMMET 08559d856d ⬆️(dimail) update dimail version to 2.11
update dimail version to last production-ready version.
2025-05-05 17:28:00 +02:00
Marie PUPO JEAMMET 8b17a5470d 🔖(changelog) fix changelog
Fixing changelog after having forgotten to update it in last release (1.16.0)
2025-05-05 12:39:34 +02:00
228 changed files with 11525 additions and 8194 deletions
+24
View File
@@ -0,0 +1,24 @@
# Set the default behavior for all files
* text=auto eol=lf
# Binary files (should not be modified)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.flv binary
*.fla binary
*.swf binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.pdf binary
+2 -1
View File
@@ -40,7 +40,7 @@ jobs:
name: Run trivy scan (frontend)
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target frontend-production -f Dockerfile'
docker-build-args: '--target frontend-production -f src/frontend/Dockerfile'
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
build-and-push-backend:
@@ -105,6 +105,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
+2 -4
View File
@@ -167,6 +167,8 @@ jobs:
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make update-keycloak-realm-app
make add-dev-rsa-private-key-to-env
make run
- name: Apply DRF migrations
@@ -177,10 +179,6 @@ jobs:
run: |
make demo FLUSH_ARGS='--no-input'
- name: Setup Dimail DB
run: |
make dimail-setup-db
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
+24 -1
View File
@@ -10,10 +10,32 @@ and this project adheres to
### Added
- 🐛(front) fix button add mail domain
- ✨(teams) add matrix webhook for teams #904
- ✨(resource-server) add SCIM /Me endpoint #895
- 🔧(git) set LF line endings for all text files #928
### Changed
- 🧑‍💻(docker) split frontend to another file #924
## [1.17.0] - 2025-06-11
### Added
- ✨(frontend) add crisp script #914
- ⚡️(fix) add error when mailbox create failed
- ✨(mailbox) allow to reset password on mailboxes #834
## [1.16.0] - 2025-05-05
### Added
- 🔧(sentry) add Celery beat task integration #892
### Changed
- ✨(uiv2) change mail domains
- 🛂(dimail) simplify interop with dimail
- ✨(mailbox) remove secondary email as required field
@@ -383,7 +405,8 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/suitenumerique/people/compare/v1.16.0...main
[unreleased]: https://github.com/suitenumerique/people/compare/v1.17.0...main
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
[1.15.0]: https://github.com/suitenumerique/people/releases/v1.15.0
[1.14.1]: https://github.com/suitenumerique/people/releases/v1.14.1
-55
View File
@@ -10,61 +10,6 @@ RUN python -m pip install --upgrade pip setuptools
RUN apk update && \
apk upgrade
### ---- Front-end dependencies image ----
FROM node:20 AS frontend-deps
WORKDIR /deps
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
RUN yarn --frozen-lockfile
### ---- Front-end builder dev image ----
FROM node:20 AS frontend-builder-dev
WORKDIR /builder
COPY --from=frontend-deps /deps/node_modules ./node_modules
COPY ./src/frontend .
WORKDIR ./apps/desk
### ---- Front-end builder image ----
FROM frontend-builder-dev AS frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.27-alpine AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=frontend-builder \
/builder/apps/desk/out \
/usr/share/nginx/html
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base AS back-builder
+41 -29
View File
@@ -41,11 +41,9 @@ DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN = $(COMPOSE) run --rm --no-deps
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -78,19 +76,33 @@ create-env-files: \
env.d/development/kc_postgresql
.PHONY: create-env-files
add-dev-rsa-private-key-to-env: ## Add a generated RSA private key to the env file
@echo "Generating RSA private key PEM for development..."
@mkdir -p env.d/development/rsa
@openssl genrsa -out env.d/development/rsa/private.pem 2048
@echo -n "\nOAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=\"" >> env.d/development/common
@openssl rsa -in env.d/development/rsa/private.pem -outform PEM >> env.d/development/common
@echo "\"" >> env.d/development/common
@rm -rf env.d/development/rsa
.PHONY: add-dev-rsa-private-key-to-env
update-keycloak-realm-app: ## Create the Keycloak realm for the project
@echo "$(BOLD)Creating Keycloak realm for 'app'$(RESET)"
@sed -i 's|http://app-dev:8000|http://app:8000|g' ./docker/auth/realm.json
.PHONY: update-keycloak-realm-app
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
bootstrap: \
data/media \
data/static \
create-env-files \
build \
run \
run-dev \
migrate \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
dimail-setup-db
.PHONY: bootstrap
# -- Docker/compose
@@ -106,19 +118,14 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d celery-beat-dev
@$(COMPOSE) up --force-recreate -d flower-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
run: ## start the wsgi (production) and servers with production Docker images
@$(COMPOSE) up --force-recreate --detach app frontend celery celery-beat nginx maildev
.PHONY: run
run-dev: ## start the servers in development mode (watch) Docker images
@$(COMPOSE) up --force-recreate --detach app-dev frontend-dev celery-dev celery-beat-dev nginx maildev
.PHONY: run-dev
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -132,6 +139,7 @@ stop: ## stop the development server using Docker
demo: ## flush db then create a demo for load testing purpose
@$(MAKE) resetdb
@$(MANAGE) create_demo
@$(MAKE) dimail-setup-db
.PHONY: demo
@@ -158,10 +166,14 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
bin/pylint --diff-only=origin/main
.PHONY: lint-pylint
lint-front:
lint-front: ## lint front-end sources with eslint
cd $(PATH_FRONT) && yarn lint
.PHONY: lint-front
lint-front-fix: ## fix front-end sources with eslint
cd $(PATH_FRONT) && yarn lint-fix
.PHONY: lint-front-fix
test: ## run project tests
@$(MAKE) test-back-parallel
.PHONY: test
@@ -182,22 +194,16 @@ test-coverage: ## compute, display and save test coverage
makemigrations: ## run django makemigrations for the people project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations $(ARGS)
.PHONY: makemigrations
migrate: ## run django migrations for the people project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate $(ARGS)
.PHONY: migrate
showmigrations: ## run django showmigrations for the people project.
@echo "$(BOLD)Running showmigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) showmigrations $(ARGS)
.PHONY: showmigrations
@@ -289,12 +295,8 @@ i18n-generate-and-upload: \
# -- INTEROPERABILTY
# -- Dimail configuration
dimail-recreate-container:
@$(COMPOSE) up --force-recreate -d dimail
.PHONY: dimail-recreate-container
dimail-setup-db:
@$(COMPOSE) up --force-recreate -d dimail
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db --populate-from-people
.PHONY: dimail-setup-db
@@ -425,3 +427,13 @@ install-secret: ## install the kubernetes secrets from Vaultwarden
fetch-domain-status:
@$(MANAGE) fetch_domain_status
.PHONY: fetch-domain-status
# -- Keycloak related
create-new-client: ## run the add-keycloak-client.sh script for keycloak.
@echo "$(BOLD)Running add-keycloak-client.sh$(RESET)"
@$(COMPOSE_RUN) \
-v ./scripts/keycloak/add-keycloak-client.sh:/tmp/add-keycloak-client.sh \
--entrypoint="/tmp/add-keycloak-client.sh" \
keycloak \
$(filter-out $@,$(MAKECMDGOALS))
.PHONY: create-new-client
+7 -1
View File
@@ -33,7 +33,7 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap
```
This command builds the `app` container, installs dependencies, performs
This command builds the `app-dev` container, installs dependencies, performs
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.
@@ -46,6 +46,12 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
$ make run
```
or if you want to run them in development mode (with live reloading):
```bash
$ make run-dev
```
You can check all available Make rules using:
```bash
+17
View File
@@ -64,6 +64,23 @@ function _dc_run() {
_docker_compose run --rm $user_args "$@"
}
# _dc_run_no_deps: wrap docker compose run command without dependencies
#
# usage: _dc_run_no_deps [options] [ARGS...]
#
# options: docker compose run command options
# ARGS : docker compose run command arguments
function _dc_run_no_deps() {
_set_user
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
fi
_docker_compose run --no-deps --rm $user_args "$@"
}
# _dc_exec: wrap docker compose exec command
#
# usage: _dc_exec [options] [ARGS...]
+1 -1
View File
@@ -35,4 +35,4 @@ fi
# Fix docker vs local path when project sources are mounted as a volume
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
_dc_run_no_deps app-dev pylint "${paths[@]}" "${args[@]}"
+125 -39
View File
@@ -1,10 +1,16 @@
services:
postgresql:
image: postgres:16
platform: linux/amd64
env_file:
- env.d/development/postgresql
ports:
- "15432:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 1s
timeout: 2s
retries: 300
redis:
image: redis:5
@@ -22,6 +28,7 @@ services:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-development
pull_policy: never
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
@@ -36,11 +43,69 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- dimail
- postgresql
- maildev
- redis
postgresql:
condition: service_healthy
restart: true
dimail:
condition: service_started
maildev:
condition: service_started
redis:
condition: service_started
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-dev
image: people:frontend-development
pull_policy: never
volumes:
- ./src/frontend:/home/frontend
- /home/frontend/node_modules
- /home/frontend/apps/desk/node_modules
ports:
- "3000:3000"
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
volumes:
- ./data/media:/data/media
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
image: people:frontend-production
pull_policy: build
ports:
- "3000:3000"
celery-dev:
user: ${DOCKER_USER:-1000}
image: people:backend-development
@@ -55,7 +120,11 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
postgresql:
condition: service_healthy
restart: true
app-dev:
condition: service_started
celery-beat-dev:
user: ${DOCKER_USER:-1000}
@@ -71,26 +140,9 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./data/media:/data/media
depends_on:
- postgresql
- redis
postgresql:
condition: service_healthy
restart: true
celery:
user: ${DOCKER_USER:-1000}
@@ -102,7 +154,29 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- app
postgresql:
condition: service_healthy
restart: true
app:
condition: service_started
celery-beat:
user: ${DOCKER_USER:-1000}
image: people:backend-production
command: ["celery", "-A", "people.celery_app", "beat", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
nginx:
image: nginx:1.25
@@ -111,11 +185,9 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- app
- keycloak
dockerize:
image: jwilder/dockerize
keycloak:
condition: service_healthy
restart: true
crowdin:
image: crowdin/cli:4.6.1
@@ -135,11 +207,16 @@ services:
- ".:/app"
kc_postgresql:
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 1s
timeout: 2s
retries: 300
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
@@ -156,6 +233,8 @@ services:
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -166,14 +245,21 @@ services:
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
healthcheck:
test: [ "CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready" ]
interval: 1s
timeout: 2s
retries: 300
ports:
- "8080:8080"
depends_on:
- kc_postgresql
kc_postgresql:
condition: service_healthy
restart: true
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.1.1
image: registry.mim-libre.fr/dimail/dimail-api:v0.2.11
pull_policy: always
environment:
DIMAIL_MODE: FAKE
+1 -1
View File
@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"sslRequired": "none",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": true,
+2 -3
View File
@@ -13,8 +13,7 @@ To ease local development, dimail provides a container that we embark in our doc
Bootstraping with command `make bootstrap` creates a container and initializes its database.
Additional commands :
- Reset the database by recreating the container with `dimail-recreate-container`.
- Populate the database with all the content of your People database with `dimail-setup-db`
- Reset and populate the database with all the content of your People database with `dimail-setup-db`
## Architecture
@@ -47,5 +46,5 @@ People users can have 3 levels of permissions on a domain:
Mailboxes can be created by a domain owners or administrators in People's domain tab.
On enabled domains, mailboxes are created at the same time on dimail (and a confirmation email is sent to the secondary email).
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's activation.
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's (re)activation.
On disabled domains, mailboxes creation is not allowed.
+14
View File
@@ -0,0 +1,14 @@
# Interoperability with Matrix API
Matrix is a protocol for secure, decentralized communication. It's the protocol used by [Tchap](https://www.tchap.gouv.fr/), another application of [La Suite](https://lasuite.numerique.gouv.fr/).
## Purpose
With webhooks using this protocol, you can automate the invitation and removal of your colleagues' accounts to Matrix chat rooms.
> [!NOTE]
> The first version of this feature requires inviting a bot and giving it moderation permission to your Matrix room. This won't be necessary in future versions of the feature, as invitations/removal will be made in your name using OIDC tokens.
## For devs
Client methods follow syntax from [Matrix Client-Server API v1.14]https://spec.matrix.org/v1.14/client-server-api/
+7
View File
@@ -0,0 +1,7 @@
# General considerations on webhooks
Webhooks are small add-ons you can link to your teams to automate the sending of information from People to other apps. Configure your webhooks to point to the tools or ressources you want to share with your group (such as a chatroom, a kanban board or a specific collection in your knowledge base). Whenever you add (or remove) someone from your team in People, access will also be granted (or revoked) to your resources.
## Requirements for webhook clients
Your webhook client must contain "invite_user_to_group" and "remove_user_from_group" methods, describing how to connect to your ressource and to grant and revoke accesses.
+4
View File
@@ -3,6 +3,7 @@ DJANGO_ALLOWED_HOSTS=*
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=people.settings
DJANGO_SUPERUSER_PASSWORD=admin
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000
# Python
PYTHONPATH=/app
@@ -65,3 +66,6 @@ IH5em4M/pHIcsqCi1qggBMbdvzHBUtC3R4sK0CpEFHlN+Y59aGazidcN2FPupNJv
MbyqKyC6DAzv4jEEhHaN7oY=
-----END PRIVATE KEY-----
"
# INTEROP
MAIL_PROVISIONING_API_CREDENTIALS="bGFfcmVnaWU6cGFzc3dvcmQ=" # Dev and test key for dimail interop
+4 -2
View File
@@ -2,7 +2,9 @@
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
OAUTH2_PROVIDER_OIDC_ENABLED = True
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD="siret"
OAUTH2_PROVIDER_OIDC_ENABLED=True
OAUTH2_PROVIDER_VALIDATOR_CLASS="mailbox_oauth2.validators.ProConnectValidator"
INSTALLED_PLUGINS=plugins.la_suite
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# Script to add a new client to Keycloak using the kcadm.sh CLI
# Usage: ./add-keycloak-client.sh [client_id] [client_secret]
# Default values
CLIENT_ID=${1:-"some-client-id"}
CLIENT_SECRET=${2:-"ThisIsAnExampleKeyForDevPurposeOnly"}
KEYCLOAK_URL=${KEYCLOAK_URL:-"http://keycloak:8080"}
KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-"admin"}
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-"admin"}
REALM=${REALM:-"people"}
# Check for kcadm.sh in common locations
KCADM_LOCATIONS=(
"/opt/keycloak/bin/kcadm.sh"
"/opt/jboss/keycloak/bin/kcadm.sh"
"/usr/local/bin/kcadm.sh"
"./bin/kcadm.sh"
"$(which kcadm.sh 2>/dev/null)"
)
KCADM=""
for loc in "${KCADM_LOCATIONS[@]}"; do
if [ -x "$loc" ]; then
KCADM="$loc"
break
fi
done
if [ -z "$KCADM" ]; then
echo "Error: kcadm.sh not found. Please specify its location manually."
echo "You can set the KCADM environment variable to the path of kcadm.sh"
exit 1
fi
echo "Using Keycloak Admin CLI at: $KCADM"
echo "Logging in to Keycloak at $KEYCLOAK_URL..."
# Login to Keycloak
$KCADM config credentials \
--server $KEYCLOAK_URL \
--realm master \
--user $KEYCLOAK_ADMIN \
--password $KEYCLOAK_ADMIN_PASSWORD
if [ $? -ne 0 ]; then
echo "Failed to login to Keycloak. Please check your credentials and try again."
exit 1
fi
echo "Successfully logged in to Keycloak."
echo "Creating new client '$CLIENT_ID' in realm '$REALM'..."
# Create a temporary JSON file with client configuration
CLIENT_JSON=$(mktemp)
cat > "$CLIENT_JSON" << EOF
{
"clientId": "$CLIENT_ID",
"name": "",
"description": "",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "$CLIENT_SECRET",
"redirectUris": [
"http://localhost:8070/*",
"http://localhost:8071/*",
"http://localhost:3200/*",
"http://localhost:8088/*",
"http://localhost:3000/*"
],
"webOrigins": [
"http://localhost:3200",
"http://localhost:8088",
"http://localhost:8070",
"http://localhost:3000"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "$(date +%s)",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"use.refresh.tokens": "true",
"tls-client-certificate-bound-access-tokens": "false",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
EOF
# Create the client using kcadm.sh
$KCADM create clients -r "$REALM" -f "$CLIENT_JSON"
if [ $? -ne 0 ]; then
echo "Failed to create client. Check the error message above."
rm "$CLIENT_JSON"
exit 1
fi
echo "✅ Client '$CLIENT_ID' created successfully!"
echo " Client ID: $CLIENT_ID"
echo " Client Secret: $CLIENT_SECRET"
# Clean up temporary file
rm "$CLIENT_JSON"
# Display the created client
echo "Client details:"
$KCADM get clients -r "$REALM" --query "clientId=$CLIENT_ID"
+7 -1
View File
@@ -586,7 +586,13 @@ class ConfigView(views.APIView):
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
array_settings = [
"LANGUAGES",
"FEATURES",
"RELEASE",
"COMMIT",
"CRISP_WEBSITE_ID",
]
dict_settings = {}
for setting in array_settings:
dict_settings[setting] = getattr(settings, setting)
@@ -0,0 +1 @@
"""SCIM compliant API for resource server"""
@@ -0,0 +1,16 @@
"""Exceptions for SCIM API."""
from django.conf import settings
from core.api.resource_server.scim.response import ScimJsonResponse
def scim_exception_handler(exc, _context):
"""Handle SCIM exceptions and return them in the correct format."""
data = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": str(exc.status_code),
"detail": str(exc.detail) if settings.DEBUG else "",
}
return ScimJsonResponse(data, status=exc.status_code)
@@ -0,0 +1,16 @@
"""SCIM API response classes."""
from rest_framework.response import Response
class ScimJsonResponse(Response):
"""
Custom JSON response class for SCIM API.
This class sets the content type to "application/json+scim" for SCIM
responses.
"""
def __init__(self, *args, **kwargs):
"""JSON response with enforced SCIM content type."""
super().__init__(*args, content_type="application/json+scim", **kwargs)
@@ -0,0 +1,90 @@
"""SCIM serializers for resource server API."""
from django.urls import reverse
from rest_framework import serializers
from core import models
class SCIMUserSerializer(serializers.ModelSerializer):
"""Serialize users in SCIM format."""
schemas = serializers.SerializerMethodField()
userName = serializers.CharField(source="sub")
displayName = serializers.CharField(source="name")
emails = serializers.SerializerMethodField()
meta = serializers.SerializerMethodField()
groups = serializers.SerializerMethodField()
active = serializers.BooleanField(source="is_active")
class Meta:
model = models.User
fields = [
"id",
"schemas",
"active",
"userName",
"displayName",
"emails",
"groups",
"meta",
]
read_only_fields = [
"id",
"schemas",
"active",
"userName",
"displayName",
"emails",
"groups",
"meta",
]
def get_schemas(self, _obj):
"""Return the SCIM schemas for the user."""
return ["urn:ietf:params:scim:schemas:core:2.0:User"]
def get_emails(self, obj):
"""Return the user's email as a list of email objects."""
if obj.email:
return [
{
"value": obj.email,
"primary": True,
"type": "work",
}
]
return []
def get_groups(self, obj):
"""
Return the groups the user belongs to.
WARNING: you need to prefetch the team accesses in the
viewset to avoid N+1 queries.
"""
return [
{
"value": str(team_access.team.external_id),
"display": team_access.team.name,
"type": "direct",
}
for team_access in obj.accesses.all()
]
def get_meta(self, obj):
"""Return metadata about the user."""
request = self.context.get("request")
location = (
f"{request.build_absolute_uri('/').rstrip('/')}{reverse('scim-me-list')}"
if request
else None
)
return {
"resourceType": "User",
"created": obj.created_at.isoformat(),
"lastModified": obj.updated_at.isoformat(),
"location": location,
}
@@ -0,0 +1,62 @@
"""Resource server SCIM API endpoints"""
from django.contrib.auth import get_user_model
from django.db.models import Prefetch, Q
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
from rest_framework import (
viewsets,
)
from core.api import permissions
from core.models import TeamAccess
from . import serializers
from .exceptions import scim_exception_handler
from .response import ScimJsonResponse
User = get_user_model()
class MeViewSet(ResourceServerMixin, viewsets.ViewSet):
"""
SCIM-compliant ViewSet for the /Me endpoint.
This endpoint provides information about the currently authenticated user
in SCIM (System for Cross-domain Identity Management) format.
Features:
- Returns user details in SCIM format.
- Includes the user's teams, restricted to the audience.
Limitations:
- Does not currently support managing Team hierarchies.
Endpoint:
GET /resource-server/v1.0/scim/Me/
Retrieves the authenticated user's details and associated teams.
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = serializers.SCIMUserSerializer
def get_exception_handler(self):
"""Override the default exception handler to use SCIM-specific handling."""
return scim_exception_handler
def list(self, request, *args, **kwargs):
"""Return the current user's details in SCIM format."""
service_provider_audience = self._get_service_provider_audience()
user = User.objects.prefetch_related(
Prefetch(
"accesses",
queryset=TeamAccess.objects.select_related("team").filter(
Q(team__service_providers__audience_id=service_provider_audience)
| Q(team__is_visible_all_services=True)
),
)
).get(pk=request.user.pk)
serializer = self.serializer_class(user, context={"request": request})
return ScimJsonResponse(serializer.data)
+7
View File
@@ -24,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices):
FAILURE = "failure", _("Failure")
PENDING = "pending", _("Pending")
SUCCESS = "success", _("Success")
class WebhookProtocolChoices(models.TextChoices):
"""Defines the possible protocols of webhook."""
SCIM = "scim"
MATRIX = "matrix"
@@ -0,0 +1,40 @@
# Generated by Django 5.2 on 2025-06-12 09:58
import uuid
from django.conf import settings
from django.db import migrations, models
def gen_team_uuid(apps, schema_editor):
Team = apps.get_model("core", "Team")
for row in Team.objects.all():
row.external_id = uuid.uuid4()
# Don't need to batch update here, as the table is likely small or empty
row.save(update_fields=["external_id"])
class Migration(migrations.Migration):
dependencies = [
('core', '0015_alter_accountservice_api_key'),
]
operations = [
migrations.AddField(
model_name='team',
name='external_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=False, verbose_name='external_id'),
),
migrations.RunPython(gen_team_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='team',
name='external_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=True, verbose_name='external_id'),
),
#
migrations.AlterField(
model_name='team',
name='users',
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', through_fields=('team', 'user'), to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-06-17 14:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_team_external_id_alter_team_users'),
]
operations = [
migrations.AddField(
model_name='teamwebhook',
name='protocol',
field=models.CharField(choices=[('scim', 'Scim'), ('matrix', 'Matrix')], default='scim'),
),
]
+31 -16
View File
@@ -21,7 +21,7 @@ from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db import models
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext, override
@@ -31,9 +31,9 @@ import jsonschema
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookStatusChoices
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
from core.plugins.registry import registry as plugin_hooks_registry
from core.utils.webhooks import scim_synchronizer
from core.utils.webhooks import webhooks_synchronizer
from core.validators import get_field_validators_from_setting
logger = getLogger(__name__)
@@ -742,6 +742,11 @@ class Team(MP_Node, BaseModel):
can see it.
When a team is created from a Service Provider this one is automatically set in the
Team `service_providers`.
The team `external_id` is used to synchronize the team with external systems, this
is the equivalent of the User `sub` field but for teams (note: `sub` is highly related
to the OIDC standard, while `external_id` is not). The `external_id` is NOT the same as
the `externalId` field in the SCIM standard when importing teams from SCIM.
"""
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
@@ -752,6 +757,14 @@ class Team(MP_Node, BaseModel):
name = models.CharField(max_length=100)
external_id = models.UUIDField(
verbose_name=_("external_id"),
help_text=_("Team external UUID for synchronization with external systems"),
unique=True,
default=uuid.uuid4,
editable=False,
)
users = models.ManyToManyField(
User,
through="TeamAccess",
@@ -851,16 +864,12 @@ class TeamAccess(BaseModel):
Override save function to fire webhooks on any addition or update
to a team access.
"""
if self._state.adding:
if self._state.adding and self.team.webhooks.exists():
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
with transaction.atomic():
instance = super().save(*args, **kwargs)
scim_synchronizer.add_user_to_group(self.team, self.user)
else:
instance = super().save(*args, **kwargs)
# try to synchronize all webhooks
webhooks_synchronizer.add_user_to_group(self.team, self.user)
return instance
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""
@@ -868,11 +877,12 @@ class TeamAccess(BaseModel):
Don't allow deleting a team access until it is successfully synchronized with all
its webhooks.
"""
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
with transaction.atomic():
arguments = self.team, self.user
super().delete(*args, **kwargs)
scim_synchronizer.remove_user_from_group(*arguments)
if webhooks := self.team.webhooks.all():
webhooks.update(status=WebhookStatusChoices.PENDING)
# try to synchronize all webhooks
webhooks_synchronizer.remove_user_from_group(self.team, self.user)
super().delete(*args, **kwargs)
def get_abilities(self, user):
"""
@@ -930,6 +940,11 @@ class TeamWebhook(BaseModel):
team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE)
url = models.URLField(_("url"))
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
protocol = models.CharField(
max_length=None,
default=WebhookProtocolChoices.SCIM,
choices=WebhookProtocolChoices.choices,
)
status = models.CharField(
max_length=10,
default=WebhookStatusChoices.PENDING,
+1
View File
@@ -0,0 +1 @@
"""Test fixtures."""
+75
View File
@@ -0,0 +1,75 @@
"""Define here some fake responses from Matrix API, useful to mock responses in tests."""
from rest_framework import status
# JOIN ROOMS
def mock_join_room_successful(room_id):
"""Mock response when succesfully joining room. Same response if already in room."""
return {"message": {"room_id": str(room_id)}, "status_code": status.HTTP_200_OK}
def mock_join_room_no_known_servers():
"""Mock response when room to join cannot be found."""
return {
"message": {"errcode": "M_UNKNOWN", "error": "No known servers"},
"status_code": status.HTTP_404_NOT_FOUND,
}
def mock_join_room_forbidden():
"""Mock response when room cannot be joined."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": "You do not belong to any of the required rooms/spaces to join this room.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
# INVITE USER
def mock_invite_successful():
"""Mock response when invite request was succesful. Does not check the user exists."""
return {"message": {}, "status_code": status.HTTP_200_OK}
def mock_invite_user_already_in_room(user):
"""Mock response when invitation forbidden for People user."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"{user.email.replace('@', ':')} is already in the room.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
# KICK USER
def mock_kick_successful():
"""Mock response when succesfully joining room."""
return {"message": {}, "status_code": status.HTTP_200_OK}
def mock_kick_user_forbidden(user):
"""Mock response when kick request is forbidden (i.e. wrong permission or user is room admin."""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"You cannot kick user @{user.email.replace('@', ':')}.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
def mock_kick_user_not_in_room():
"""
Mock response when trying to kick a user who isn't in the room. Don't check the user exists.
"""
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": "The target user is not in the room",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
@@ -0,0 +1 @@
"""Tests for the resource server SCIM API endpoints."""
@@ -0,0 +1,186 @@
"""
Tests for the SCIM Me API endpoint in People's core app
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_405_METHOD_NOT_ALLOWED,
)
from core import factories
from core.models import RoleChoices
pytestmark = pytest.mark.django_db
def test_api_me_anonymous(client):
"""Anonymous users should not be allowed to access the Me endpoint."""
response = client.get("/resource-server/v1.0/scim/Me/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "401",
"detail": "",
}
def test_api_me_authenticated(
client, force_login_via_resource_server, django_assert_num_queries
):
"""
Authenticated users should be able to access their own information
in SCIM format.
"""
user = factories.UserFactory(name="Test User", email="test@example.com")
service_provider = factories.ServiceProviderFactory()
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(2):
response = client.get(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": str(user.pk),
"active": True,
"userName": user.sub,
"displayName": user.name,
"emails": [
{
"value": user.email,
"primary": True,
"type": "work",
}
],
"groups": [],
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat(),
"lastModified": user.updated_at.isoformat(),
"location": "http://testserver/resource-server/v1.0/scim/Me/",
},
}
def test_api_me_authenticated_with_team_access(
client, force_login_via_resource_server, django_assert_num_queries
):
"""
Authenticated users with TeamAccess should see their team information
in SCIM format, but only for teams visible to the service provider.
"""
user = factories.UserFactory(name="Test User", email="test@example.com")
service_provider = factories.ServiceProviderFactory()
# Create teams with different visibility settings
team_visible = factories.TeamFactory(name="Visible Team")
team_visible.service_providers.add(service_provider)
team_all_services = factories.TeamFactory(
name="All Services Team", is_visible_all_services=True
)
team_not_visible = factories.TeamFactory(name="Not Visible Team")
# This team is not associated with the service provider
# Add user to all teams
factories.TeamAccessFactory(user=user, team=team_visible, role=RoleChoices.MEMBER)
factories.TeamAccessFactory(
user=user, team=team_all_services, role=RoleChoices.ADMIN
)
factories.TeamAccessFactory(
user=user, team=team_not_visible, role=RoleChoices.OWNER
)
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(
2
): # User + TeamAccess (with select_related teams)
response = client.get(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": str(user.pk),
"active": True,
"userName": user.sub,
"displayName": user.name,
"emails": [
{
"value": user.email,
"primary": True,
"type": "work",
}
],
"groups": [
{
"value": str(team_visible.external_id),
"display": team_visible.name,
"type": "direct",
},
{
"value": str(team_all_services.external_id),
"display": team_all_services.name,
"type": "direct",
},
],
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat(),
"lastModified": user.updated_at.isoformat(),
"location": "http://testserver/resource-server/v1.0/scim/Me/",
},
}
@pytest.mark.parametrize(
"http_method",
["post", "put", "patch", "delete"],
ids=["POST", "PUT", "PATCH", "DELETE"],
)
def test_api_me_method_not_allowed(
client, force_login_via_resource_server, http_method
):
"""Test that methods other than GET are not allowed for the Me endpoint."""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
client_method = getattr(client, http_method)
response = client_method(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": str(HTTP_405_METHOD_NOT_ALLOWED),
"detail": "",
}
@@ -3,14 +3,17 @@ Test for team accesses API endpoints in People's core app : create
"""
import json
import logging
import random
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories, models
from core import enums, factories, models
from core.tests.fixtures import matrix
pytestmark = pytest.mark.django_db
@@ -171,14 +174,17 @@ def test_api_team_accesses_create_authenticated_owner():
}
def test_api_team_accesses_create_webhook():
def test_api_team_accesses_create__with_scim_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
If a team has a SCIM webhook, creating a team access should fire a call
with the expected payload.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(team=team)
webhook = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
@@ -226,3 +232,139 @@ def test_api_team_accesses_create_webhook():
}
],
}
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
def test_api_team_accesses_create__multiple_webhooks_success(caplog):
"""
When the team has multiple webhooks, creating a team access should fire all the expected calls.
If all responses are positive, proceeds to add the user to the team.
"""
caplog.set_level(logging.INFO)
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook_scim = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
)
webhook_matrix = factories.TeamWebhookFactory(
team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
protocol=enums.WebhookProtocolChoices.MATRIX,
secret="yo",
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
# Logger
log_messages = [msg.message for msg in caplog.records]
for webhook in [webhook_scim, webhook_matrix]:
assert (
f"add_user_to_group synchronization succeeded with {webhook.url}"
in log_messages
)
# Status
for webhook in [webhook_scim, webhook_matrix]:
webhook.refresh_from_db()
assert webhook.status == "success"
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__multiple_webhooks_failure(caplog):
"""When a webhook fails, user should still be added to the team."""
caplog.set_level(logging.INFO)
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook_scim = factories.TeamWebhookFactory(
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
)
webhook_matrix = factories.TeamWebhookFactory(
team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
protocol=enums.WebhookProtocolChoices.MATRIX,
secret="secret",
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
responses.patch(
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_forbidden()["message"]),
status=str(matrix.mock_join_room_forbidden()["status_code"]),
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"add_user_to_group synchronization succeeded with {webhook_scim.url}"
in log_messages
)
assert (
f"add_user_to_group synchronization failed with {webhook_matrix.url}"
in log_messages
)
# Status
webhook_scim.status = "success"
webhook_matrix.status = "failure"
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@@ -19,6 +19,7 @@ def test_api_config_anonymous():
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"CRISP_WEBSITE_ID": None,
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"COMMIT": "NA",
"FEATURES": {
@@ -42,6 +43,7 @@ def test_api_config_authenticated():
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"CRISP_WEBSITE_ID": None,
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
"COMMIT": "NA",
"FEATURES": {
@@ -0,0 +1,277 @@
"""Test Team synchronization webhooks : focus on matrix client."""
import json
import logging
import re
from django.test import override_settings
import pytest
import responses
from rest_framework import status
from core import factories
from core.enums import WebhookProtocolChoices
from core.tests.fixtures import matrix
from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db
## INVITE
@responses.activate
def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
"""Cannot invite when Matrix returns forbidden. This might mean the user is an admin."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
error = matrix.mock_kick_user_forbidden(user)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/invite"),
body=str(error["message"]),
status=error["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
@responses.activate
def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
"""If user is already in room, webhooks should be set to success."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_user_already_in_room(user)["message"]),
status=matrix.mock_invite_user_already_in_room(user)["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"add_user_to_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__invite_user_to_room_success(caplog):
"""The user passed to the function should get invited."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Check headers
headers = responses.calls[0].request.headers
assert webhook.secret in headers["Authorization"]
# Check payloads sent to Matrix API
assert json.loads(responses.calls[1].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}",
"reason": f"User added to team {webhook.team} on People",
}
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"add_user_to_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
@override_settings(TCHAP_ACCESS_TOKEN="TCHAP_TOKEN")
def test_matrix_webhook__override_secret_for_tchap():
"""The user passed to the function should get invited."""
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.tchap.gouv.fr/#/room/room_id:home_server",
secret=None,
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
)
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
# Check headers
headers = responses.calls[0].request.headers
assert "TCHAP_TOKEN" in headers["Authorization"]
## KICK
@responses.activate
def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
"""Webhook should report a success when user was already not in room."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_user_not_in_room()["message"]),
status=matrix.mock_kick_user_not_in_room()["status_code"],
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"remove_user_from_group synchronization succeeded with {webhook.url}"
in log_messages
)
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__kick_user_from_room_success(caplog):
"""The user passed to the function should get removed."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_successful),
status=status.HTTP_200_OK,
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Check payloads sent to Matrix API
assert json.loads(responses.calls[1].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}",
"reason": f"User removed from team {webhook.team} on People",
}
# Logger
log_messages = [msg.message for msg in caplog.records]
expected_messages = (
f"remove_user_from_group synchronization succeeded with {webhook.url}"
)
assert expected_messages in log_messages
# Status
webhook.refresh_from_db()
assert webhook.status == "success"
@responses.activate
def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
"""Cannot kick an admin."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
# Mock successful responses
error = matrix.mock_kick_user_forbidden(user)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/kick"),
body=str(error["message"]),
status=error["status_code"],
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Logger
log_messages = [msg.message for msg in caplog.records]
assert (
f"remove_user_from_group synchronization failed with {webhook.url}"
in log_messages
)
# Status
webhook.refresh_from_db()
assert webhook.status == "failure"
@@ -1,4 +1,4 @@
"""Test Team synchronization webhooks."""
"""Test Team synchronization webhooks : focus on scim client"""
import json
import random
@@ -10,7 +10,7 @@ import pytest
import responses
from core import factories
from core.utils.webhooks import scim_synchronizer
from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db
@@ -20,7 +20,7 @@ def test_utils_webhooks_add_user_to_group_no_webhooks():
access = factories.TeamAccessFactory()
with responses.RequestsMock():
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert len(responses.calls) == 0
@@ -42,7 +42,7 @@ def test_utils_webhooks_add_user_to_group_success(mock_info):
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -107,7 +107,7 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
content_type="application/json",
)
scim_synchronizer.remove_user_from_group(access.team, access.user)
webhooks_synchronizer.remove_user_from_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -163,11 +163,11 @@ def test_utils_webhooks_add_user_to_group_failure(mock_error):
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=random.choice([404, 301, 302]),
status=404,
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i, webhook in enumerate(webhooks):
assert rsps.calls[i].request.url == webhook.url
@@ -228,7 +228,7 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
rsps.add(rsps.PATCH, url, status=200, content_type="application/json"),
]
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
for i in range(4):
assert all_rsps[i].call_count == 1
@@ -285,7 +285,7 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_error):
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert rsp.call_count == 5
assert rsps.calls[0].request.url == webhook.url
@@ -339,7 +339,7 @@ def test_utils_webhooks_add_user_to_group_authorization():
content_type="application/json",
)
scim_synchronizer.add_user_to_group(access.team, access.user)
webhooks_synchronizer.add_user_to_group(access.team, access.user)
assert rsps.calls[0].request.url == webhook.url
# Check headers
+136
View File
@@ -0,0 +1,136 @@
"""Matrix client for interoperability to synchronize with remote service providers."""
import logging
from django.conf import settings
import requests
from rest_framework.status import (
HTTP_200_OK,
)
from urllib3.util import Retry
logger = logging.getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=4,
backoff_factor=0.1,
status_forcelist=[500, 502],
allowed_methods=["POST"],
)
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
class MatrixAPIClient:
"""A client to interact with Matrix API"""
secret = settings.TCHAP_ACCESS_TOKEN
def get_headers(self, webhook):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
if "tchap.gouv.fr" in webhook.url:
token = settings.TCHAP_ACCESS_TOKEN
elif webhook.secret:
token = webhook.secret
else:
raise ValueError("Please configure this webhook's secret access token.")
headers["Authorization"] = f"Bearer {token}"
return headers
def _get_room_url(self, webhook_url):
"""Returns room id from webhook url."""
room_id = webhook_url.split("/room/")[1]
base_url = room_id.split(":")[1]
if "tchap.gouv.fr" in webhook_url:
base_url = f"matrix.{base_url}"
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
def get_user_id(self, user):
"""Returns user id from email."""
if user.email is None:
raise ValueError("You must first set an email for the user.")
return f"@{user.email.replace('@', ':')}"
def join_room(self, webhook):
"""Accept invitation to the room. As of today, it is a mandatory step
to make sure our account will be able to invite/remove users."""
return session.post(
f"{self._get_room_url(webhook.url)}/join",
json={},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
def add_user_to_group(self, webhook, user):
"""Send request to invite an user to a room or space upon adding them to group.."""
join_response = self.join_room(webhook)
if join_response.status_code != HTTP_200_OK:
logger.error(
"Synchronization failed (cannot join room) %s",
webhook.url,
)
return join_response, False
user_id = self.get_user_id(user)
response = session.post(
f"{self._get_room_url(webhook.url)}/invite",
json={
"user_id": user_id,
"reason": f"User added to team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
# Checks for false negative
# (i.e. trying to invite user already in room)
webhook_succeeded = False
if (
response.status_code == HTTP_200_OK
or b"is already in the room." in response.content
):
webhook_succeeded = True
return response, webhook_succeeded
def remove_user_from_group(self, webhook, user):
"""Send request to kick an user from a room or space upon removing them from group."""
join_response = self.join_room(webhook)
if join_response.status_code != HTTP_200_OK:
logger.error(
"Synchronization failed (cannot join room) %s",
webhook.url,
)
return join_response, False
user_id = self.get_user_id(user)
response = session.post(
f"{self._get_room_url(webhook.url)}/kick",
json={
"user_id": user_id,
"reason": f"User removed from team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=False,
timeout=3,
)
# Checks for false negative
# (i.e. trying to remove user who already left the room)
webhook_succeeded = False
if (
response.status_code == HTTP_200_OK
or b"The target user is not in the room" in response.content
):
webhook_succeeded = True
return response, webhook_succeeded
+6 -2
View File
@@ -39,7 +39,7 @@ class SCIMClient:
],
}
return session.patch(
response = session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
@@ -47,6 +47,8 @@ class SCIMClient:
timeout=3,
)
return response, response.ok
def remove_user_from_group(self, webhook, user):
"""Remove a user from a group by its ID or email."""
payload = {
@@ -61,10 +63,12 @@ class SCIMClient:
}
],
}
return session.patch(
response = session.patch(
webhook.url,
json=payload,
headers=webhook.get_headers(),
verify=False,
timeout=3,
)
return response, response.ok
+42 -24
View File
@@ -4,14 +4,16 @@ import logging
import requests
from core import enums
from core.enums import WebhookStatusChoices
from .matrix import MatrixAPIClient
from .scim import SCIMClient
logger = logging.getLogger(__name__)
class WebhookSCIMClient:
class WebhookClient:
"""Wraps the SCIM client to record call results on webhooks."""
def __getattr__(self, name):
@@ -26,31 +28,15 @@ class WebhookSCIMClient:
if not webhook.url:
continue
client = SCIMClient()
status = WebhookStatusChoices.FAILURE
try:
response = getattr(client, name)(webhook, user)
response, webhook_succeeded = self._get_response_and_status(
name, webhook, user
)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
extra = {
"response": response.content,
}
if response is not None:
extra = {"response": response.content}
# pylint: disable=no-member
if response.status_code == requests.codes.ok:
if webhook_succeeded:
logger.info(
"%s synchronization succeeded with %s",
name,
@@ -71,5 +57,37 @@ class WebhookSCIMClient:
return wrapper
def _get_client(self, webhook):
"""Get client depending on the protocol."""
if webhook.protocol == enums.WebhookProtocolChoices.MATRIX:
return MatrixAPIClient()
scim_synchronizer = WebhookSCIMClient()
return SCIMClient()
def _get_response_and_status(self, name, webhook, user):
"""Get response from webhook outside party."""
client = self._get_client(webhook)
try:
response, webhook_succeeded = getattr(client, name)(webhook, user)
except requests.exceptions.RetryError as exc:
logger.error(
"%s synchronization failed due to max retries exceeded with url %s",
name,
webhook.url,
exc_info=exc,
)
except requests.exceptions.RequestException as exc:
logger.error(
"%s synchronization failed with %s.",
name,
webhook.url,
exc_info=exc,
)
else:
return response, webhook_succeeded
return None, False
webhooks_synchronizer = WebhookClient()
@@ -21,7 +21,11 @@ from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailboxStatusChoices, MailDomainStatusChoices
from mailbox_manager.enums import (
MailboxStatusChoices,
MailDomainRoleChoices,
MailDomainStatusChoices,
)
fake = Faker()
@@ -267,10 +271,11 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.push(
models.TeamAccess(team_id=team_id, user_id=user_id, role=role[0])
)
queue.flush()
with Timeit(stdout, "Creating domains"):
for i in range(defaults.NB_OBJECTS["domains"]):
name = f"{fake.domain_name()}-i{i:d}"
name = fake.domain_name().replace(".", f"-i{i:d}.")
queue.push(
mailbox_models.MailDomain(
@@ -366,6 +371,19 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
# OIDC configuration
if settings.OAUTH2_PROVIDER.get("OIDC_ENABLED", False):
stdout.write("Creating OIDC client for People Identity Provider")
@@ -40,13 +40,16 @@ def test_commands_create_demo(settings):
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"] + 1
assert (
mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1 + 1
)
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights
# 1 domain for E2E mail owner user
assert (
mailbox_models.MailDomainAccess.objects.count()
== TEST_NB_OBJECTS["domains"] + 3 + 9
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 1
)
Binary file not shown.
+58 -7
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-28 14:01+0000\n"
"PO-Revision-Date: 2025-04-28 15:40\n"
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
"PO-Revision-Date: 2025-06-11 09:29\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -422,6 +422,25 @@ msgstr ""
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
msgid "Send pending mailboxes to dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
@@ -533,16 +552,21 @@ msgstr ""
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:260
#: mailbox_manager/utils/dimail.py:260
#: build/lib/mailbox_manager/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
msgid "Your new mailbox information"
msgstr ""
#: build/lib/people/settings.py:158 people/settings.py:158
msgid "English"
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
msgid "Your password has been updated"
msgstr ""
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr ""
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr ""
@@ -603,12 +627,14 @@ msgstr ""
#: core/templates/mail/html/maildomain_failed.html:233
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/reset_password.html:268
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_action_required.txt:14
#: core/templates/mail/text/maildomain_enabled.txt:14
#: core/templates/mail/text/maildomain_failed.txt:15
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/reset_password.txt:16
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr ""
@@ -715,7 +741,9 @@ msgid "Welcome aboard!"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/html/reset_password.html:159
#: core/templates/mail/text/new_mailbox.txt:3
#: core/templates/mail/text/reset_password.txt:3
msgid "La Messagerie"
msgstr ""
@@ -740,25 +768,48 @@ msgid "Please find below your login info: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/html/reset_password.html:223
#: core/templates/mail/text/new_mailbox.txt:10
#: core/templates/mail/text/reset_password.txt:9
msgid "Email address: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/html/reset_password.html:228
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
#: core/templates/mail/text/reset_password.txt:10
msgid "Temporary password (to be modified on first login): "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/html/reset_password.html:256
#: core/templates/mail/text/new_mailbox.txt:13
#: core/templates/mail/text/reset_password.txt:12
msgid "Go to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/html/reset_password.html:267
#: core/templates/mail/text/new_mailbox.txt:15
#: core/templates/mail/text/reset_password.txt:14
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/reset_password.html:188
#: core/templates/mail/text/reset_password.txt:5
msgid "Your password has been reset"
msgstr ""
#: core/templates/mail/html/reset_password.html:194
#: core/templates/mail/text/reset_password.txt:6
msgid "Your password has been reset."
msgstr ""
#: core/templates/mail/html/reset_password.html:199
#: core/templates/mail/text/reset_password.txt:7
msgid "Please find below your new login information: "
msgstr ""
#: core/templates/mail/html/team_invitation.html:206
#: core/templates/mail/text/team_invitation.txt:7
#, python-format
Binary file not shown.
+69 -18
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-28 14:01+0000\n"
"PO-Revision-Date: 2025-04-28 15:40\n"
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
"PO-Revision-Date: 2025-06-11 09:29\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -19,7 +19,7 @@ msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Personal info"
msgstr "Infos Personnelles"
msgstr "Informations personnelles"
#: build/lib/core/admin.py:73 core/admin.py:73
msgid "Permissions"
@@ -43,7 +43,7 @@ msgstr "Les plugins de post-création ont été exécutés pour les organisation
#: build/lib/core/apps.py:65 core/apps.py:65
msgid "People core application"
msgstr ""
msgstr "Application cœur de People"
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
@@ -59,12 +59,12 @@ msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'or
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
msgid "Invalid authorization header."
msgstr ""
msgstr "En-tête d'autorisation invalide."
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
msgid "Invalid api key."
msgstr ""
msgstr "Clé API invalide."
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Failure"
@@ -270,7 +270,7 @@ msgstr "Cet utilisateur est déjà dans cette organisation."
#: build/lib/core/models.py:774 core/models.py:774
msgid "is visible for all SP"
msgstr "est visible pour tous les SP"
msgstr "est visible pour tous les fournisseurs de service"
#: build/lib/core/models.py:776 core/models.py:776
msgid "Whether this team is visible to all service providers."
@@ -335,27 +335,27 @@ msgstr "[La Suite] Vous avez été invité(e) à être %(role)s d'un groupe"
#: build/lib/core/models.py:1108 core/models.py:1108
msgid "api key"
msgstr ""
msgstr "Clé d'API"
#: build/lib/core/models.py:1113 core/models.py:1113
msgid "allowed scopes"
msgstr ""
msgstr "périmètres autorisés"
#: build/lib/core/models.py:1114 core/models.py:1114
msgid "Allowed scopes for this service"
msgstr ""
msgstr "Périmètres d'application autorisés pour ce service"
#: build/lib/core/models.py:1119 core/models.py:1119
msgid "Account service"
msgstr ""
msgstr "Compte de service"
#: build/lib/core/models.py:1120 core/models.py:1120
msgid "Account services"
msgstr ""
msgstr "Comptes de service"
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
msgid "Import emails from dimail"
msgstr ""
msgstr "Importer les emails depuis dimail"
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#, python-format
@@ -422,6 +422,25 @@ msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
@@ -533,16 +552,21 @@ msgstr "Invitations au domaine de messagerie"
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: build/lib/mailbox_manager/utils/dimail.py:260
#: mailbox_manager/utils/dimail.py:260
#: build/lib/mailbox_manager/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: build/lib/people/settings.py:158 people/settings.py:158
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr "Anglais"
#: build/lib/people/settings.py:159 people/settings.py:159
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr "Français"
@@ -603,12 +627,14 @@ msgstr "Cordialement,"
#: core/templates/mail/html/maildomain_failed.html:233
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/reset_password.html:268
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_action_required.txt:14
#: core/templates/mail/text/maildomain_enabled.txt:14
#: core/templates/mail/text/maildomain_failed.txt:15
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/reset_password.txt:16
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr "L'équipe de La Suite"
@@ -715,7 +741,9 @@ msgid "Welcome aboard!"
msgstr "Bienvenue à bord !"
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/html/reset_password.html:159
#: core/templates/mail/text/new_mailbox.txt:3
#: core/templates/mail/text/reset_password.txt:3
msgid "La Messagerie"
msgstr "La Messagerie"
@@ -740,25 +768,48 @@ msgid "Please find below your login info: "
msgstr "Voici vos identifiants de connexion : "
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/html/reset_password.html:223
#: core/templates/mail/text/new_mailbox.txt:10
#: core/templates/mail/text/reset_password.txt:9
msgid "Email address: "
msgstr "Adresse email : "
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/html/reset_password.html:228
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
#: core/templates/mail/text/reset_password.txt:10
msgid "Temporary password (to be modified on first login): "
msgstr "Mot de passe temporaire (à modifier à la première connexion) : "
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/html/reset_password.html:256
#: core/templates/mail/text/new_mailbox.txt:13
#: core/templates/mail/text/reset_password.txt:12
msgid "Go to La Messagerie"
msgstr "Accéder à La Messagerie"
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/html/reset_password.html:267
#: core/templates/mail/text/new_mailbox.txt:15
#: core/templates/mail/text/reset_password.txt:14
msgid "Sincerely,"
msgstr "Cordialement,"
#: core/templates/mail/html/reset_password.html:188
#: core/templates/mail/text/reset_password.txt:5
msgid "Your password has been reset"
msgstr "Votre mot de passe a été réinitialisé"
#: core/templates/mail/html/reset_password.html:194
#: core/templates/mail/text/reset_password.txt:6
msgid "Your password has been reset."
msgstr "Votre mot de passe a été réinitialisé."
#: core/templates/mail/html/reset_password.html:199
#: core/templates/mail/text/reset_password.txt:7
msgid "Please find below your new login information: "
msgstr "Veuillez trouver ci-dessous vos nouvelles informations de connexion : "
#: core/templates/mail/html/team_invitation.html:206
#: core/templates/mail/text/team_invitation.txt:7
#, python-format
+34
View File
@@ -135,6 +135,39 @@ def fetch_domain_expected_config_from_dimail(modeladmin, request, queryset): #
)
@admin.action(description=_("Send pending mailboxes to dimail"))
def send_pending_mailboxes(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Send pending mailboxes"""
client = DimailAPIClient()
excluded_domains = []
for domain in queryset:
# do not check disabled domains
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
results = client.send_pending_mailboxes(domain)
if failed_mailboxes := results["failed_mailboxes"]:
messages.error(
request,
_("Failed to send the following mailboxes : %(mailboxes)s.")
% {"mailboxes": ", ".join(failed_mailboxes)},
)
else:
messages.success(
request,
_("Pending mailboxes successfully sent for %(domain)s.")
% {"domain": domain.name},
)
if excluded_domains:
messages.warning(
request,
_("Domains disabled are excluded from : %(domains)s")
% {"domains": ", ".join(excluded_domains)},
)
class UserMailDomainAccessInline(admin.TabularInline):
"""Inline admin class for mail domain accesses."""
@@ -163,6 +196,7 @@ class MailDomainAdmin(admin.ModelAdmin):
sync_mailboxes_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
send_pending_mailboxes,
)
autocomplete_fields = ["organization"]
@@ -249,6 +249,9 @@ class MailBoxViewSet(
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/enable/
Send a request to dimail to enable mailbox and change status of the mailbox in our DB
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/reset/
Send a request to mail-provider to reset password.
"""
permission_classes = [permissions.MailBoxPermission]
@@ -293,6 +296,15 @@ class MailBoxViewSet(
mailbox.save()
return Response(serializers.MailboxSerializer(mailbox).data)
@action(detail=True, methods=["post"])
def reset_password(self, request, domain_slug, pk=None): # pylint: disable=unused-argument
"""Send a request to dimail to change password
and email new password to mailbox's secondary email."""
mailbox = self.get_object()
dimail = DimailAPIClient()
dimail.reset_password(mailbox)
return Response(serializers.MailboxSerializer(mailbox).data)
class MailDomainInvitationViewset(
mixins.CreateModelMixin,
@@ -47,9 +47,9 @@ class Command(BaseCommand):
f"This command is not meant to run in {settings.CONFIGURATION} 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
# 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=("", ""),
name=admin["username"],
@@ -57,8 +57,8 @@ class Command(BaseCommand):
perms=[],
)
# Create Regie user, auth for all remaining requests
# and your own dev
# Create Regie user,
# auth for all remaining requests and your own local setup
self.create_user(
auth=(admin["username"], admin["password"]),
name=regie["username"],
@@ -66,28 +66,22 @@ class Command(BaseCommand):
perms=["new_domain", "create_users", "manage_users"],
)
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
)[0]
self.create_domain(domain_name)
# 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
# Create a test domain for local development
try:
people_base_user = User.objects.get(email="people@people.world")
except User.DoesNotExist:
self.stdout.write("people@people.world user not found", ending="\n")
else:
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
)[0]
self.create_domain(domain_name)
# create accesses for john doe
self.create_user(name=people_base_user.sub)
self.create_allow(people_base_user.sub, domain_name)
MailDomainAccess.objects.get_or_create(
user=people_base_user,
domain=domain,
@@ -97,7 +91,7 @@ class Command(BaseCommand):
if options["populate_from_people"]:
self._populate_dimail_from_people()
self.stdout.write("DONE", ending="\n")
self.stdout.write("DONE 🎉", ending="\n")
def create_user(
self,
@@ -148,41 +142,12 @@ class Command(BaseCommand):
)
)
def create_allow(self, user, domain):
"""
Send a request to create a new allows between user and domain using DimailAPIClient.
"""
response = self.client.create_allow(user, domain)
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']}"
)
)
def _populate_dimail_from_people(self):
self.stdout.write("Creating accounts from people database", ending="\n")
user_to_create = set()
domain_to_create = set()
access_to_create = set()
for mail_access in MailDomainAccess.objects.select_related(
"domain", "user"
).all():
user_to_create.add(mail_access.user)
domain_to_create.add(mail_access.domain)
access_to_create.add(mail_access)
"""Populate dimail so that it reflects people's domains."""
self.stdout.write("Creating domain from people database", ending="\n")
# create missing domains
for domain in domain_to_create:
for domain in MailDomain.objects.all():
# enforce domain status
if domain.status != enums.MailDomainStatusChoices.ENABLED:
self.stdout.write(
@@ -191,15 +156,3 @@ class Command(BaseCommand):
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
self.create_domain(domain.name)
# create missing users
for user in user_to_create:
self.create_user(
auth=(admin["username"], admin["password"]),
name=user.sub,
perms=[], # no permission needed for "classic" users
)
# create missing accesses
for access in access_to_create:
self.create_allow(access.user.sub, access.domain.name)
+5 -2
View File
@@ -23,7 +23,6 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
Convert valid domain invitations to domain accesses for a given user.
Expired invitations are ignored.
"""
logger.info("Convert domain invitations for user %s", instance)
if created:
valid_domain_invitations = MailDomainInvitation.objects.filter(
email=instance.email,
@@ -36,6 +35,11 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
if not valid_domain_invitations.exists():
return
logger.info(
"Converting %s domain invitations for new user %s",
len(valid_domain_invitations),
instance,
)
MailDomainAccess.objects.bulk_create(
[
MailDomainAccess(
@@ -46,4 +50,3 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
)
valid_domain_invitations.delete()
logger.info("Invitations converted to domain accesses for user %s", instance)
@@ -141,3 +141,26 @@ def test_api_domain_invitations__should_not_create_duplicate_invitations():
assert response.json()["__all__"] == [
"Mail domain invitation with this Email address and Domain already exists."
]
def test_api_domain_invitations__should_not_invite_when_user_already_exists():
"""Already existing users should not be invited but given access directly."""
existing_user = core_factories.UserFactory()
# Grant privileged role on the domain to the user
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build(email=existing_user.email)
).data
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
@@ -550,7 +550,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.
Succesfull 201 response from dimail should trigger mailbox creation on our side.
successful 201 response from dimail should trigger mailbox creation on our side.
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=role)
@@ -805,7 +805,7 @@ def test_api_mailboxes__send_correct_logger_infos(mock_info, mock_error):
assert not mock_error.called
# Check all expected log messages are present, order doesn't matter
expected_messages = {
("Token succesfully granted by mail-provisioning API.",),
("Token successfully granted by mail-provisioning API.",),
(
"Mailbox successfully created on domain %s by user %s",
str(access.domain),
@@ -0,0 +1,195 @@
"""
Unit tests for the reset password mailbox API
"""
from unittest import mock
from django.conf import settings
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
from mailbox_manager.tests.fixtures import dimail
pytestmark = pytest.mark.django_db
def test_api_mailboxes__reset_password_anonymous_unauthorized():
"""Anonymous users should not be able to reset mailboxes password."""
mailbox = factories.MailboxFactory(status=enums.MailboxStatusChoices.ENABLED)
response = APIClient().post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__reset_password_unrelated_forbidden():
"""Authenticated users not managing the domain
should not be able to reset its mailboxes password."""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
mailbox = factories.MailboxFactory(status=enums.MailboxStatusChoices.ENABLED)
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_mailboxes__reset_password_viewer_forbidden():
"""Domain viewers should not be able to reset passwords on mailboxes."""
mailbox = factories.MailboxEnabledFactory()
viewer_access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.VIEWER, domain=mailbox.domain
)
client = APIClient()
client.force_login(viewer_access.user)
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_mailboxes__reset_password_no_secondary_email():
"""Should not try to reset password if no secondary email is specified."""
mail_domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.OWNER, domain=mail_domain
)
client = APIClient()
client.force_login(access.user)
error = "Password reset requires a secondary email address. \
Please add a valid secondary email before trying again."
# Mailbox with no secondary email
mailbox = factories.MailboxEnabledFactory(domain=mail_domain, secondary_email=None)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == [error]
# Mailbox with empty secondary email
mailbox = factories.MailboxEnabledFactory(domain=mail_domain, secondary_email="")
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == [error]
# Mailbox with primary email as secondary email
mailbox = factories.MailboxEnabledFactory(domain=mail_domain)
mailbox.secondary_email = str(mailbox)
mailbox.save()
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == [error]
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
],
)
@responses.activate
def test_api_mailboxes__reset_password_admin_successful(role):
"""Owner and admin users should be able to reset password on mailboxes.
New password should be sent to secondary email."""
mail_domain = factories.MailDomainEnabledFactory()
mailbox = factories.MailboxEnabledFactory(domain=mail_domain)
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
client = APIClient()
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
body=dimail.response_mailbox_created(str(mailbox)),
status=200,
)
with mock.patch("django.core.mail.send_mail") as mock_send:
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert mock_send.call_count == 1
assert "Your password has been updated" in mock_send.mock_calls[0][1][1]
assert mock_send.mock_calls[0][1][3][0] == mailbox.secondary_email
assert response.status_code == status.HTTP_200_OK
def test_api_mailboxes__reset_password_non_existing():
"""
User gets a 404 when trying to reset password of mailbox which does not exist.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/mail-domains/nonexistent.domain/mailboxes/")
assert response.status_code == status.HTTP_404_NOT_FOUND
@responses.activate
def test_api_mailboxes__reset_password_connexion_failed():
"""
No mail is sent when password reset failed because of connexion error.
"""
mail_domain = factories.MailDomainEnabledFactory()
mailbox = factories.MailboxEnabledFactory(domain=mail_domain)
access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.OWNER, domain=mail_domain
)
client = APIClient()
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
body=ConnectionError(),
)
with pytest.raises(ConnectionError):
with mock.patch("django.core.mail.send_mail") as mock_send:
client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert mock_send.call_count == 0
@@ -6,8 +6,6 @@ from django.core.management import call_command
import pytest
import responses
from core import factories
from mailbox_manager import factories as mailbox_factories
from mailbox_manager.management.commands.setup_dimail_db import DIMAIL_URL, admin
@@ -22,23 +20,12 @@ def test_commands_setup_dimail_db(settings):
"""The create_demo management command should create objects as expected."""
settings.DEBUG = True # required to run the command
john_doe = factories.UserFactory(
name="John Doe", email="people@people.world", sub="sub.john.doe"
)
# mock dimail API
responses.add(responses.POST, f"{DIMAIL_URL}/users/", status=201)
responses.add(responses.POST, f"{DIMAIL_URL}/domains/", status=201)
responses.add(responses.POST, f"{DIMAIL_URL}/allows/", status=201)
responses.add(
responses.GET,
responses.post(f"{DIMAIL_URL}/users/", status=201)
responses.post(f"{DIMAIL_URL}/domains/", status=201)
responses.get(
f"{DIMAIL_URL}/users/",
json=[
{
"is_admin": False,
"name": john_doe.sub,
"perms": [],
},
{
"is_admin": True,
"name": "admin",
@@ -59,7 +46,7 @@ def test_commands_setup_dimail_db(settings):
call_command("setup_dimail_db")
# check dimail API received the expected requests
assert len(responses.calls) == 5
assert len(responses.calls) == 2
assert responses.calls[0].request.url == f"{DIMAIL_URL}/users/"
assert (
responses.calls[0].request.body
@@ -72,25 +59,6 @@ def test_commands_setup_dimail_db(settings):
b'"perms": ["new_domain", "create_users", "manage_users"]}'
)
assert responses.calls[2].request.url == f"{DIMAIL_URL}/domains/"
assert responses.calls[2].request.body == (
b'{"name": "test.domain.com", "context_name": "test.domain.com", '
b'"features": ["webmail", "mailbox", "alias"], '
b'"delivery": "virtual"}'
)
assert responses.calls[3].request.url == f"{DIMAIL_URL}/users/"
assert (
responses.calls[3].request.body
== b'{"name": "sub.john.doe", "password": "no", "is_admin": false, "perms": []}'
)
assert responses.calls[4].request.url == f"{DIMAIL_URL}/allows/"
assert (
responses.calls[4].request.body
== b'{"user": "sub.john.doe", "domain": "test.domain.com"}'
)
# reset the responses counter
responses.calls.reset() # pylint: disable=no-member
@@ -102,29 +70,14 @@ def test_commands_setup_dimail_db(settings):
call_command("setup_dimail_db", "--populate-from-people")
# check dimail API received the expected requests
assert (
len(responses.calls) == 5 + 3 + 3
) # calls for some.domain.com and test.domain.com
assert len(responses.calls) == 3 # calls for some.domain.com and test.domain.com
dimail_calls = []
for call in responses.calls[5:]:
for call in responses.calls[3:]:
dimail_calls.append((call.request.url, call.request.body))
assert (
f"{DIMAIL_URL}/domains/",
(
b'{"name": "some.domain.com", "context_name": "some.domain.com", '
b'"features": ["webmail", "mailbox", "alias"], '
b'"delivery": "virtual"}'
),
) in dimail_calls
assert (
f"{DIMAIL_URL}/users/",
(b'{"name": "sub.toto.123", "password": "no", "is_admin": false, "perms": []}'),
) in dimail_calls
assert (
f"{DIMAIL_URL}/allows/",
b'{"user": "sub.toto.123", "domain": "some.domain.com"}',
) in dimail_calls
assert responses.calls[2].request.body == (
b'{"name": "some.domain.com", "context_name": "some.domain.com", '
b'"features": ["webmail", "mailbox", "alias"], '
b'"delivery": "virtual"}'
)
+3 -3
View File
@@ -7,7 +7,7 @@ import json
def response_user_created(user_sub):
"""mimic dimail response upon succesfull user creation."""
"""mimic dimail response upon successful user creation."""
return json.dumps(
{
"name": user_sub,
@@ -297,7 +297,7 @@ TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
def response_allows_created(user_name, domain_name):
"""mimic dimail response upon succesfull allows creation.
"""mimic dimail response upon successful allows creation.
Dimail expects a name but our names are ProConnect's uuids."""
return json.dumps({"user": user_name, "domain": domain_name})
@@ -306,5 +306,5 @@ def response_allows_created(user_name, domain_name):
def response_mailbox_created(email_address):
"""mimic dimail response upon succesfull mailbox creation."""
"""mimic dimail response upon successful mailbox creation."""
return json.dumps({"email": email_address, "password": "password"})
@@ -38,11 +38,15 @@ def test_models_domain_invitations__is_expired():
assert expired_invitation.is_expired is True
def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_joining():
def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_joining(
caplog,
):
"""
Upon creating a new user, domain invitations linked to that email
should be converted to accesses and then deleted.
"""
caplog.set_level("INFO")
# Two invitations to the same mail but to different domains
email = "future_admin@example.com"
invitation_to_domain1 = factories.MailDomainInvitationFactory(
@@ -81,3 +85,8 @@ def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_j
assert models.MailDomainInvitation.objects.filter(
domain=invitation_to_domain2.domain, email=other_invitation.email
).exists() # the other invitation remains
log_messages = [msg.message for msg in caplog.records]
assert (
f"Converting 2 domain invitations for new user {str(new_user)}" in log_messages
)
@@ -204,3 +204,83 @@ def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(clie
assert "Domains disabled are excluded from fetch" in response.content.decode(
"utf-8"
)
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes(client):
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=enums.MailDomainStatusChoices.ENABLED)
mailboxes = factories.MailboxFactory.create_batch(
3, status=enums.MailboxStatusChoices.PENDING, domain=domain
)
data = {
"action": "send_pending_mailboxes",
"_selected_action": [domain.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
for mailbox in mailboxes:
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"{mailbox.local_part}@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
for mailbox in mailboxes:
mailbox.refresh_from_db()
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes__listing_failed_mailboxes(client):
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=enums.MailDomainStatusChoices.ENABLED)
mailbox = factories.MailboxFactory(
status=enums.MailboxStatusChoices.PENDING, domain=domain
)
data = {
"action": "send_pending_mailboxes",
"_selected_action": [domain.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"{mailbox.local_part}@{domain.name}"),
status=status.HTTP_409_CONFLICT,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
assert (
f"Failed to send the following mailboxes : {str(mailbox)}"
in response.content.decode("utf-8")
)
mailbox.refresh_from_db()
assert mailbox.status == enums.MailboxStatusChoices.PENDING
@@ -348,9 +348,9 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
assert domain.last_check_details == body_domain_ok
def test_dimail__enable_pending_mailboxes(caplog):
def test_dimail__send_pending_mailboxes(caplog):
"""Status of pending mailboxes should switch to "enabled"
when calling enable_pending_mailboxes."""
when calling send_pending_mailboxes."""
caplog.set_level(logging.INFO)
domain = factories.MailDomainFactory()
@@ -380,7 +380,7 @@ def test_dimail__enable_pending_mailboxes(caplog):
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.enable_pending_mailboxes(domain=domain)
dimail_client.send_pending_mailboxes(domain=domain)
mailbox1.refresh_from_db()
mailbox2.refresh_from_db()
@@ -388,7 +388,7 @@ def test_dimail__enable_pending_mailboxes(caplog):
assert mailbox2.status == enums.MailboxStatusChoices.ENABLED
log_messages = [msg.message for msg in caplog.records]
assert "Token succesfully granted by mail-provisioning API." in log_messages
assert "Token successfully granted by mail-provisioning API." in log_messages
assert (
f"Mailbox successfully created on domain {domain.name} by user None"
in log_messages
+124 -42
View File
@@ -1,3 +1,5 @@
# pylint: disable=line-too-long
"""A minimalist client to synchronize with mailbox provisioning API."""
import ast
@@ -49,18 +51,27 @@ class DimailAPIClient:
Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
to get a token from dimail /token/ endpoint.
"""
response = requests.get(
f"{self.API_URL}/token/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
timeout=self.API_TIMEOUT,
)
try:
response = requests.get(
f"{self.API_URL}/token/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if response.status_code == status.HTTP_200_OK:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {response.json()['access_token']}",
}
logger.info("Token succesfully granted by mail-provisioning API.")
logger.info("Token successfully granted by mail-provisioning API.")
return headers
if response.status_code == status.HTTP_403_FORBIDDEN:
@@ -254,35 +265,63 @@ class DimailAPIClient:
Send email to confirm mailbox creation
and send new mailbox information.
"""
title = _("Your new mailbox information")
template_name = "new_mailbox"
self._send_mailbox_related_email(
title, template_name, recipient, mailbox_data, issuer
)
def notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
"""
Send email to notify of password reset
and send new password.
"""
title = _("Your password has been updated")
template_name = "reset_password"
self._send_mailbox_related_email(
title, template_name, recipient, mailbox_data, issuer
)
# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
def _send_mailbox_related_email(
self, title, template_name, recipient, mailbox_data, issuer=None
):
"""
Send email with new mailbox or password reset information.
"""
context = {
"title": title,
"site": Site.objects.get_current(),
"webmail_url": settings.WEBMAIL_URL,
"mailbox_data": mailbox_data,
}
try:
with override(issuer.language if issuer else settings.LANGUAGE_CODE):
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)
mail.send_mail(
template_vars["title"],
msg_plain,
context["title"],
render_to_string(f"mail/text/{template_name}.txt", context),
settings.EMAIL_FROM,
[recipient],
html_message=msg_html,
html_message=render_to_string(
f"mail/html/{template_name}.html", context
),
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error(
"Failed to send mailbox information to %s was not sent: %s",
recipient,
exception,
)
else:
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,
)
def import_mailboxes(self, domain):
"""Import mailboxes from dimail - open xchange in our database.
@@ -388,29 +427,34 @@ class DimailAPIClient:
return response
return self.raise_exception_for_unexpected_response(response)
def enable_pending_mailboxes(self, domain):
"""Send requests for all pending mailboxes of a domain."""
def send_pending_mailboxes(self, domain):
"""Send requests for all pending mailboxes of a domain. Returns a list of failed mailboxes for this domain."""
failed_mailboxes = []
for mailbox in domain.mailboxes.filter(
status=enums.MailboxStatusChoices.PENDING
):
response = self.create_mailbox(mailbox)
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox.save()
if mailbox.secondary_email:
# send confirmation email
self.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=response.json(),
)
try:
response = self.create_mailbox(mailbox)
except requests.exceptions.HTTPError:
failed_mailboxes.append(str(mailbox))
else:
logger.warning(
"Email notification for %s creation not sent "
"because no secondary email found",
mailbox,
)
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox.save()
if mailbox.secondary_email and mailbox.secondary_email != str(mailbox):
# send confirmation email
self.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=response.json(),
)
else:
logger.warning(
"Email notification for %s creation not sent "
"because no valid secondary email found",
mailbox,
)
return {"failed_mailboxes": failed_mailboxes}
def check_domain(self, domain):
"""Send a request to dimail to check domain health."""
@@ -459,7 +503,7 @@ class DimailAPIClient:
domain.status != enums.MailDomainStatusChoices.ENABLED
and dimail_state == "ok"
):
self.enable_pending_mailboxes(domain)
self.send_pending_mailboxes(domain)
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.last_check_details = dimail_response
domain.save()
@@ -561,3 +605,41 @@ class DimailAPIClient:
exc_info=False,
)
return []
def reset_password(self, mailbox):
"""Send a request to reset mailbox password."""
if not mailbox.secondary_email or mailbox.secondary_email == str(mailbox):
raise exceptions.ValidationError(
"Password reset requires a secondary email address. Please add a valid secondary email before trying again."
)
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
headers=self.get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.exception(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if response.status_code == status.HTTP_200_OK:
# send new password to secondary email
self.notify_mailbox_password_reset(
recipient=mailbox.secondary_email,
mailbox_data={
"email": response.json()["email"],
"password": response.json()["password"],
},
)
logger.info(
"[DIMAIL] Password reset on mailbox %s.",
mailbox,
)
return response
return self.raise_exception_for_unexpected_response(response)
+9 -3
View File
@@ -3,18 +3,23 @@
from django.urls import include, path
from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls
from rest_framework.routers import DefaultRouter
from rest_framework.routers import SimpleRouter
from core.api.resource_server import viewsets
from core.api.resource_server.scim import viewsets as scim_viewsets
# - Main endpoints
# Contacts will be added later
# Users will be added later
router = DefaultRouter()
router = SimpleRouter()
router.register("teams", viewsets.TeamViewSet, basename="teams")
# - SCIM endpoints
scim_router = SimpleRouter()
scim_router.register("Me", scim_viewsets.MeViewSet, basename="scim-me")
# - Routes nested under a team
team_related_router = DefaultRouter()
team_related_router = SimpleRouter()
team_related_router.register(
"invitations",
viewsets.InvitationViewset,
@@ -33,6 +38,7 @@ urlpatterns = [
*router.urls,
*resource_server_urls,
path("teams/<uuid:team_id>/", include(team_related_router.urls)),
path("scim/", include(scim_router.urls)),
]
),
),
+17 -1
View File
@@ -14,6 +14,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
import json
import os
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -547,6 +548,13 @@ class Base(Configuration):
environ_prefix=None,
)
# SUPPORT
CRISP_WEBSITE_ID = values.Value(
default=None,
environ_name="CRISP_WEBSITE_ID",
environ_prefix=None,
)
# MAILBOX-PROVISIONING API
WEBMAIL_URL = values.Value(
default=None,
@@ -593,6 +601,11 @@ class Base(Configuration):
environ_name="DNS_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
TCHAP_ACCESS_TOKEN = values.Value(
default=None,
environ_name="TCHAP_ACCESS_TOKEN",
environ_prefix=None,
)
# Organizations
ORGANIZATION_REGISTRATION_ID_VALIDATORS = json.loads(
@@ -935,7 +948,10 @@ class Production(Base):
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
+20 -20
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.16.0"
version = "1.17.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -27,14 +27,15 @@ requires-python = ">=3.11"
dependencies = [
"Brotli==1.1.0",
"PyJWT==2.10.1",
"boto3==1.38.5",
"celery[redis]==5.5.2",
"django-celery-beat==2.8.0",
"boto3==1.38.33",
"celery[redis]==5.5.3",
"django-celery-beat==2.8.1",
"django-celery-results==2.6.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-lasuite==0.0.7",
"django-extensions==4.1",
"django-lasuite==0.0.9",
"django-oauth-toolkit==3.0.1",
"django-parler==2.3",
"django-redis==5.4.0",
@@ -42,7 +43,7 @@ dependencies = [
"django-timezone-field>=5.1",
"django-treebeard==4.7.1",
"django-zxcvbn-password-validator==1.4.5",
"django==5.2",
"django==5.2.3",
"djangorestframework==3.16.0",
"dockerflow==2024.4.2",
"drf_spectacular==0.28.0",
@@ -51,14 +52,14 @@ dependencies = [
"factory_boy==3.3.3",
"flower==2.0.1",
"gunicorn==23.0.0",
"joserfc==1.0.4",
"jsonschema==4.23.0",
"joserfc==1.1.0",
"jsonschema==4.24.0",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.6",
"psycopg[binary]==3.2.9",
"redis==5.2.1",
"requests==2.32.3",
"sentry-sdk[django]==2.27.0",
"requests==2.32.4",
"sentry-sdk[django]==2.29.1",
"whitenoise==6.9.0",
]
@@ -70,23 +71,22 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.4.1",
"drf-spectacular-sidecar==2025.6.1",
"ipdb==0.13.13",
"ipython==9.2.0",
"ipython==9.3.0",
"jq==1.8.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.6",
"pylint==3.3.7",
"pytest-cov==6.1.1",
"pytest-django==4.11.1",
"pytest==8.3.5",
"pytest==8.4.0",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.11.7",
"types-requests==2.32.0.20250328",
"freezegun==1.5.1",
"ruff==0.11.13",
"types-requests==2.32.0.20250602",
"freezegun==1.5.2",
]
[tool.setuptools]
+63
View File
@@ -0,0 +1,63 @@
FROM node:22-alpine AS frontend-deps
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
RUN yarn install --frozen-lockfile
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/.prettierrc.js ./.prettierrc.js
COPY ./src/frontend/packages/eslint-config-people ./packages/eslint-config-people
COPY ./src/frontend/apps/desk ./apps/desk
### ---- Front-end builder image ----
FROM frontend-deps AS frontend-base
WORKDIR /home/frontend/apps/desk
### ---- Front-end builder dev image ----
FROM frontend-deps AS frontend-dev
WORKDIR /home/frontend/apps/desk
EXPOSE 3000
CMD [ "yarn", "dev"]
FROM frontend-base AS frontend-builder
WORKDIR /home/frontend/apps/desk
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=frontend-builder \
/home/frontend/apps/desk/out \
/usr/share/nginx/html
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
+1 -1
View File
@@ -1 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
+2
View File
@@ -1,6 +1,8 @@
server {
listen 3000;
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
+4 -372
View File
@@ -1,373 +1,5 @@
const config = {
themes: {
default: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-bg': '#FAFAFA',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
},
font: {
sizes: {
ml: '0.938rem',
xl: '1.50rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
},
weights: {
thin: 100,
extrabold: 800,
black: 900,
},
},
spacings: {
'0': '0',
none: '0',
auto: 'auto',
bx: '2.2rem',
full: '100%',
},
breakpoints: {
xxs: '320px',
xs: '480px',
},
},
components: {
datagrid: {
header: {
weight: 'var(--c--theme--font--weights--extrabold)',
size: 'var(--c--theme--font--sizes--ml)',
},
cell: {
color: 'var(--c--theme--colors--primary-500)',
size: 'var(--c--theme--font--sizes--ml)',
},
},
'forms-checkbox': {
'background-color': {
hover: '#055fd214',
},
color: 'var(--c--theme--colors--primary-500)',
'font-size': 'var(--c--theme--font--sizes--ml)',
},
'forms-datepicker': {
'border-color': 'var(--c--theme--colors--primary-500)',
'value-color': 'var(--c--theme--colors--primary-500)',
'border-radius': {
hover: 'var(--c--components--forms-datepicker--border-radius)',
focus: 'var(--c--components--forms-datepicker--border-radius)',
},
},
'forms-field': {
color: 'var(--c--theme--colors--primary-500)',
'value-color': 'var(--c--theme--colors--primary-500)',
width: 'auto',
},
'forms-input': {
'value-color': 'var(--c--theme--colors--primary-500)',
'border-color': 'var(--c--theme--colors--primary-500)',
color: {
error: 'var(--c--theme--colors--danger-500)',
'error-hover': 'var(--c--theme--colors--danger-500)',
'box-shadow-error-hover': 'var(--c--theme--colors--danger-500)',
},
},
'forms-labelledbox': {
'label-color': {
small: 'var(--c--theme--colors--primary-500)',
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
big: {
disabled: 'var(--c--theme--colors--greyscale-400)',
},
},
},
'forms-select': {
'border-color': 'var(--c--theme--colors--primary-500)',
'border-color-disabled-hover':
'var(--c--theme--colors--greyscale-200)',
'border-radius': {
hover: 'var(--c--components--forms-select--border-radius)',
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#ffffff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
},
'forms-switch': {
'accent-color': 'var(--c--theme--colors--primary-400)',
},
'forms-textarea': {
'border-color': 'var(--c--components--forms-textarea--border-color)',
'border-color-hover':
'var(--c--components--forms-textarea--border-color)',
'border-radius': {
hover: 'var(--c--components--forms-textarea--border-radius)',
focus: 'var(--c--components--forms-textarea--border-radius)',
},
color: 'var(--c--theme--colors--primary-500)',
disabled: {
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
},
},
modal: {
'background-color': '#ffffff',
},
button: {
'border-radius': {
active: 'var(--c--components--button--border-radius)',
},
'medium-height': 'auto',
'small-height': 'auto',
success: {
color: 'white',
'color-disabled': 'white',
'color-hover': 'white',
background: {
color: 'var(--c--theme--colors--success-600)',
'color-disabled': 'var(--c--theme--colors--greyscale-300)',
'color-hover': 'var(--c--theme--colors--success-800)',
},
},
danger: {
'color-hover': 'white',
background: {
color: 'var(--c--theme--colors--danger-400)',
'color-hover': 'var(--c--theme--colors--danger-500)',
'color-disabled': 'var(--c--theme--colors--danger-100)',
},
},
primary: {
color: 'var(--c--theme--colors--primary-text)',
'color-active': 'var(--c--theme--colors--primary-text)',
background: {
color: 'var(--c--theme--colors--primary-400)',
'color-active': 'var(--c--theme--colors--primary-500)',
},
border: {
'color-active': 'transparent',
},
},
secondary: {
color: 'var(--c--theme--colors--primary-500)',
'color-hover': 'var(--c--theme--colors--primary-text)',
background: {
color: 'white',
'color-hover': 'var(--c--theme--colors--primary-700)',
},
border: {
color: 'var(--c--theme--colors--primary-200)',
},
},
tertiary: {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
disabled: {
color: 'white',
background: {
color: '#b3cef0',
},
},
},
},
},
dsfr: {
theme: {
colors: {
'card-border': '#DDDDDD',
'primary-text': '#000091',
'primary-100': '#f5f5fe',
'primary-150': '#F4F4FD',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-700': '#272747',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
'secondary-400': '#e1020f',
'secondary-500': '#c91a1f',
'secondary-600': '#5e2b2b',
'secondary-700': '#3b2424',
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
'success-300': '#88fdaa',
'success-400': '#3bea7e',
'success-500': '#1f8d49',
'success-600': '#18753c',
'success-700': '#204129',
'success-800': '#1e2e22',
'success-900': '#19281d',
'info-text': '#0078f3',
'info-100': '#f4f6ff',
'info-200': '#e8edff',
'info-300': '#dde5ff',
'info-400': '#bdcdff',
'info-500': '#0078f3',
'info-600': '#0063cb',
'info-700': '#f4f6ff',
'info-800': '#222a3f',
'info-900': '#1d2437',
'warning-text': '#d64d00',
'warning-100': '#fff4f3',
'warning-200': '#ffe9e6',
'warning-300': '#ffded9',
'warning-400': '#ffbeb4',
'warning-500': '#d64d00',
'warning-600': '#b34000',
'warning-700': '#5e2c21',
'warning-800': '#3e241e',
'warning-900': '#361e19',
'danger-text': '#e1000f',
'danger-100': '#fef4f4',
'danger-200': '#fee9e9',
'danger-300': '#fddede',
'danger-400': '#fcbfbf',
'danger-500': '#e1000f',
'danger-600': '#c9191e',
'danger-700': '#642727',
'danger-800': '#412121',
'danger-900': '#3a1c1c',
},
font: {
families: {
accent: 'Marianne',
base: 'Marianne',
},
},
},
components: {
alert: {
'border-radius': '0',
},
button: {
'medium-height': '48px',
'border-radius': '4px',
primary: {
background: {
color: 'var(--c--theme--colors--primary-text)',
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
},
secondary: {
background: {
'color-hover': '#F6F6F6',
'color-active': '#EDEDED',
},
border: {
color: 'var(--c--theme--colors--primary-600)',
'color-hover': 'var(--c--theme--colors--primary-600)',
},
color: 'var(--c--theme--colors--primary-text)',
},
'tertiary-text': {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
},
},
datagrid: {
header: {
color: 'var(--c--theme--colors--primary-text)',
size: 'var(--c--theme--font--sizes--s)',
},
body: {
'background-color': 'transparent',
'background-color-hover': '#F4F4FD',
},
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
},
},
'forms-checkbox': {
'border-radius': '0',
color: 'var(--c--theme--colors--primary-text)',
},
'forms-datepicker': {
'border-radius': '0',
},
'forms-fileuploader': {
'border-radius': '0',
},
'forms-field': {
color: 'var(--c--theme--colors--primary-text)',
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
},
'forms-labelledbox': {
'label-color': {
big: 'var(--c--theme--colors--primary-text)',
},
},
'forms-select': {
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
},
'forms-switch': {
'handle-border-radius': '2px',
'rail-border-radius': '4px',
},
'forms-textarea': {
'border-radius': '0',
},
},
},
},
};
import { cunninghamConfig } from '@gouvfr-lasuite/ui-kit';
export default config;
export default {
...cunninghamConfig,
};
+22 -16
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.16.0",
"version": "1.17.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -15,28 +15,30 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@gouvfr-lasuite/integration": "1.0.2",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "^0.5.0",
"@hookform/resolvers": "4.0.0",
"@openfun/cunningham-react": "3.0.0",
"@tanstack/react-query": "5.72.1",
"i18next": "24.2.3",
"@openfun/cunningham-react": "3.1.0",
"@tanstack/react-query": "5.76.1",
"i18next": "25.1.3",
"i18next-browser-languagedetector": "8.1.0",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.2.5",
"next": "15.3.2",
"react": "*",
"react-dom": "*",
"react-hook-form": "7.55.0",
"react-i18next": "15.4.1",
"react-hook-form": "7.56.3",
"react-i18next": "15.5.1",
"react-select": "5.10.1",
"sass": "1.86.3",
"styled-components": "6.1.17",
"zod": "3.24.2",
"zustand": "5.0.3"
"sass": "1.89.0",
"styled-components": "6.1.18",
"zod": "3.24.4",
"zustand": "5.0.4"
},
"devDependencies": {
"@hookform/devtools": "4.4.0",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.72.1",
"@tanstack/react-query-devtools": "5.76.1",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
@@ -45,18 +47,22 @@
"@types/lodash": "4.17.16",
"@types/luxon": "3.6.2",
"@types/node": "*",
"@types/react": "19.1.0",
"@types/react": "19.1.4",
"@types/react-dom": "*",
"dotenv": "16.4.7",
"dotenv": "16.5.0",
"eslint-config-people": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.5.3",
"stylelint": "16.18.0",
"stylelint": "16.19.1",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*"
},
"resolutions": {
"react": "19.0.0",
"react-dom": "19.0.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -37,6 +37,7 @@ describe('Page', () => {
useConfigStore.setState({
config: {
CRISP_WEBSITE_ID: 'wesh',
RELEASE: '1.0.0',
COMMIT: 'NA',
FEATURES: { TEAMS_DISPLAY: true },
@@ -66,6 +67,7 @@ describe('Page', () => {
useConfigStore.setState({
config: {
CRISP_WEBSITE_ID: 'wesh',
RELEASE: '1.0.0',
COMMIT: 'NA',
FEATURES: { TEAMS_DISPLAY: false },
@@ -95,6 +97,7 @@ describe('Page', () => {
useConfigStore.setState({
config: {
CRISP_WEBSITE_ID: 'wesh',
RELEASE: '1.0.0',
COMMIT: 'NA',
FEATURES: { TEAMS_DISPLAY: false },
@@ -124,6 +127,7 @@ describe('Page', () => {
useConfigStore.setState({
config: {
CRISP_WEBSITE_ID: 'wesh',
RELEASE: '1.0.0',
COMMIT: 'NA',
FEATURES: { TEAMS_DISPLAY: true },
@@ -0,0 +1,6 @@
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1196 5.07095L17.3203 5.98796C17.4154 6.00474 17.4706 6.10543 17.4328 6.19433C17.2714 6.57329 17.1581 6.80005 17.0539 6.96275C16.9667 7.09909 16.8998 7.16937 16.8295 7.22281C16.7557 7.27883 16.6168 7.3625 16.3363 7.44595C15.0129 7.83959 14.2592 9.23154 14.6528 10.5549C15.0465 11.8784 16.4384 12.6321 17.7618 12.2384C19.4239 11.744 20.5126 10.8341 21.2653 9.65807C21.5989 9.13681 21.842 8.60181 22.0329 8.15355C22.1128 7.966 22.1849 7.79074 22.2525 7.62637L22.254 7.62267C22.4259 7.20484 22.8671 6.96601 23.312 7.04447C24.401 7.23649 25.1671 7.65773 25.6102 8.30817C26.0604 8.95985 26.1888 9.83372 25.9956 10.9298L23.0029 27.9022C22.8084 29.0053 22.3888 29.7825 21.7441 30.2339C21.1065 30.6865 20.2432 30.8168 19.1541 30.6248L7.96177 28.6513C6.87274 28.4593 6.10253 28.0409 5.65114 27.3963C5.20677 26.7529 5.08183 25.8796 5.27634 24.7765L8.26903 7.80411C8.4623 6.70806 8.87836 5.9302 9.51723 5.47054C10.1631 5.01212 11.0306 4.87892 12.1196 5.07095ZM13.2049 19.4392C13.6814 20.1198 14.3259 20.5317 15.1384 20.675C15.9509 20.8182 16.6974 20.6516 17.378 20.175C18.0586 19.6985 18.4705 19.054 18.6138 18.2415C18.757 17.429 18.5904 16.6825 18.1138 16.0019C17.6373 15.3213 16.9927 14.9094 16.1803 14.7661C15.3678 14.6229 14.6213 14.7895 13.9407 15.2661C13.2601 15.7426 12.8482 16.3872 12.7049 17.1996C12.5616 18.0121 12.7283 18.7586 13.2049 19.4392ZM8.27886 25.0247C8.19255 25.5142 8.5194 25.981 9.00891 26.0673L19.0539 27.8385C19.5434 27.9248 20.0102 27.598 20.0966 27.1084L20.3049 25.9267C20.3787 25.5081 20.3389 25.1045 20.1853 24.7156C20.0317 24.3268 19.7927 24.0086 19.4683 23.761C18.7724 23.2448 18.0473 22.8218 17.2931 22.4921C16.5389 22.1624 15.7555 21.9259 14.9431 21.7826C14.1306 21.6394 13.3136 21.5937 12.4921 21.6456C11.6707 21.6975 10.8446 21.8469 10.0141 22.094C9.62458 22.2157 9.29116 22.433 9.01387 22.7458C8.73658 23.0587 8.56104 23.4244 8.48724 23.8429L8.27886 25.0247Z" fill="#000091"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.694 3.88339C24.7066 4.43554 24.2692 4.89337 23.7171 4.90599C23.4094 4.91303 22.8638 4.99795 22.3424 5.23172C21.8251 5.46364 21.4224 5.79861 21.219 6.25464C21.0916 6.54023 20.9867 6.79533 20.88 7.05474C20.8115 7.22114 20.7424 7.38931 20.6661 7.56844C20.4842 7.99554 20.2799 8.43826 20.0151 8.85213C19.4512 9.73317 18.6551 10.4143 17.3473 10.8033C16.818 10.9608 16.2612 10.6593 16.1037 10.1299C15.9463 9.60059 16.2478 9.04381 16.7771 8.88635C17.6126 8.63784 18.0184 8.26165 18.3305 7.774C18.5036 7.50353 18.6559 7.18409 18.826 6.78475C18.8824 6.65241 18.9442 6.50248 19.01 6.34276C19.1273 6.05827 19.2574 5.7427 19.3924 5.44002C19.8608 4.38982 20.7275 3.76397 21.5241 3.40676C22.3167 3.05141 23.1294 2.91891 23.6714 2.90652C24.2235 2.89389 24.6813 3.33125 24.694 3.88339Z" fill="#C9191E"/>
<path d="M12.9899 3.0145C12.4754 2.81379 12.221 2.23399 12.4217 1.71946C12.6224 1.20494 13.2022 0.950538 13.7167 1.15125C14.2217 1.34824 14.9402 1.75073 15.5634 2.35572C16.0249 2.80379 16.4723 3.40191 16.7083 4.14453C16.7414 4.2487 16.6503 4.34682 16.5427 4.32784L14.5057 3.96866C14.3783 3.9462 14.2631 3.88087 14.1703 3.79076C13.7603 3.39276 13.2766 3.12635 12.9899 3.0145Z" fill="#C9191E"/>
<path d="M17.1398 10.8419C17.077 10.8132 17.0162 10.7776 16.9585 10.7348C16.6026 10.4708 16.308 10.1964 16.0646 9.91118C16.0338 9.455 16.3205 9.02217 16.7771 8.88635C17.0849 8.7948 17.3346 8.68583 17.5424 8.56059C17.6949 8.74952 17.8921 8.93714 18.1499 9.12841C18.5324 9.41212 18.6542 9.91688 18.4695 10.3349C18.1431 10.5197 17.772 10.677 17.3473 10.8033C17.2782 10.8239 17.209 10.8365 17.1398 10.8419Z" fill="#C9191E"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+47 -8
View File
@@ -1,6 +1,6 @@
import { ComponentPropsWithRef } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
import { CSSProperties, RuleSet } from 'styled-components/dist/types';
import {
MarginPadding,
@@ -8,26 +8,35 @@ import {
stylesPadding,
} from '@/utils/styleBuilder';
import { hideEffect, showEffect } from './Effect';
export interface BoxProps {
as?: keyof HTMLElementTagNameMap;
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$color?: CSSProperties['color'];
$css?: string;
$css?: string | RuleSet<object>;
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$flex?: boolean;
$effect?: 'show' | 'hide';
$flex?: CSSProperties['flex'];
$gap?: CSSProperties['gap'];
$hasTransition?: boolean | 'slow';
$height?: CSSProperties['height'];
$justify?: CSSProperties['justifyContent'];
$overflow?: CSSProperties['overflow'];
$maxWidth?: CSSProperties['maxWidth'];
$margin?: MarginPadding;
$maxHeight?: CSSProperties['maxHeight'];
$minHeight?: CSSProperties['minHeight'];
$maxWidth?: CSSProperties['maxWidth'];
$minWidth?: CSSProperties['minWidth'];
$padding?: MarginPadding;
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$shrink?: CSSProperties['flexShrink'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
$zIndex?: CSSProperties['zIndex'];
}
@@ -41,18 +50,48 @@ export const Box = styled('div')<BoxProps>`
${({ $color }) => $color && `color: ${$color};`}
${({ $direction }) => $direction && `flex-direction: ${$direction};`}
${({ $display }) => $display && `display: ${$display};`}
${({ $flex }) => $flex === false && `display: block;`}
${({ $flex }) => $flex && `flex: ${$flex};`}
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
$hasTransition && $hasTransition === 'slow'
? `transition: all 0.5s ease-in-out;`
: $hasTransition
? `transition: all 0.3s ease-in-out;`
: ''}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $margin }) => $margin && stylesMargin($margin)}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight};`}
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight};`}
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
${({ $padding }) => $padding && stylesPadding($padding)}
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
${({ $css }) => $css && `${$css};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`}
${({ $effect }) => {
let effect;
switch ($effect) {
case 'show':
effect = showEffect;
break;
case 'hide':
effect = hideEffect;
break;
}
return (
effect &&
`
transition: all 0.3s ease-in-out;
${effect}
`
);
}}
`;
@@ -1,8 +1,13 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
export type BoxButtonType = BoxType & {
disabled?: boolean;
};
/**
/**
* Styleless button that extends the Box component.
@@ -17,21 +22,34 @@ export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
return (
<Box
ref={ref}
as="button"
$background="none"
$css={`
cursor: pointer;
$margin="none"
$padding="none"
$css={css`
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
border: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: inherit;
color: ${props.disabled
? 'var(--c--theme--colors--greyscale-400) !important'
: 'inherit'};
${$css || ''}
`}
{...props}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
}
props.onClick?.(event);
}}
/>
);
},
@@ -4,11 +4,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { Box, BoxType } from '.';
export const Card = ({
children,
$css,
...props
}: PropsWithChildren<BoxType>) => {
export const Card = ({ children, ...props }: PropsWithChildren<BoxType>) => {
const { colorsTokens } = useCunninghamTheme();
return (
@@ -16,9 +12,7 @@ export const Card = ({
$background="white"
$radius="4px"
$css={`
box-shadow: 2px 2px 5px ${colorsTokens()['primary-300']}88;
border: 1px solid ${colorsTokens()['card-border']};
${$css}
border: 1px solid ${colorsTokens()['greyscale-050']};
`}
{...props}
>
@@ -0,0 +1,11 @@
export const showEffect = `
transform: scaleY(1);
opacity: 1;
max-height: 150px;
`;
export const hideEffect = `
transform: scaleY(0);
opacity: 0;
max-height: 0;
`;
@@ -0,0 +1,63 @@
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type IconProps = TextType & {
iconName: string;
};
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
};
interface IconBGProps extends TextType {
iconName: string;
}
export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Text
$isMaterialIcon
$size="36px"
$theme="primary"
$variation="600"
$background={colorsTokens()['primary-050']}
$css={`
border: 1px solid ${colorsTokens()['primary-200']};
user-select: none;
`}
$radius="12px"
$padding="4px"
$margin="auto"
{...textProps}
>
{iconName}
</Text>
);
};
type IconOptionsProps = TextType & {
isHorizontal?: boolean;
};
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
return (
<Text
{...props}
$isMaterialIcon
$theme="primary"
$css={css`
user-select: none;
${props.$css}
`}
>
{isHorizontal ? 'more_horiz' : 'more_vert'}
</Text>
);
};
@@ -1,22 +0,0 @@
import { Text } from '@/components';
interface IconOptionsProps {
isOpen: boolean;
'aria-label': string;
}
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
return (
<Text
aria-label={props['aria-label']}
className="material-icons"
$theme="primary"
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
`}
>
more_vert
</Text>
);
};
@@ -0,0 +1,44 @@
import React, { InputHTMLAttributes } from 'react';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
label: string;
error?: string;
};
export const Input = ({ label, error, required, ...props }: InputProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box $display="flex" $gap="4px">
<label
htmlFor={label}
style={{ fontWeight: 500, color: colorsTokens()['greyscale-900'] }}
>
{label} {required && '*'}
</label>
<input
id={label}
aria-required={required}
required={required}
style={{
padding: '12px',
margin: '6px 0',
borderRadius: '4px',
fontSize: '14px',
border: `1px solid ${error ? colorsTokens()['danger-500'] : colorsTokens()['greyscale-400']}`,
background: colorsTokens()['greyscale-050'],
color: colorsTokens()['greyscale-900'],
}}
{...props}
/>
{error && (
<Text $size="xs" $color="error-500">
{error}
</Text>
)}
</Box>
);
};
@@ -0,0 +1,89 @@
import { Tooltip } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
interface TagContentProps {
status: 'pending' | 'enabled' | 'disabled' | 'failed' | 'action_required';
showTooltip?: boolean;
tooltipType?: 'domain' | 'mail';
placement?: 'top' | 'bottom';
}
const TagContent = ({ status }: TagContentProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const textColor = {
pending: colorsTokens()['info-600'],
enabled: colorsTokens()['success-600'],
disabled: colorsTokens()['greyscale-600'],
action_required: colorsTokens()['warning-600'],
failed: colorsTokens()['danger-600'],
};
const backgroundColor = {
pending: colorsTokens()['info-100'],
enabled: colorsTokens()['success-100'],
disabled: colorsTokens()['greyscale-100'],
action_required: colorsTokens()['warning-100'],
failed: colorsTokens()['danger-100'],
};
return (
<Box
$background={backgroundColor[status]}
$color={textColor[status]}
$radius="4px"
$css={`
padding: 4px 8px;
font-weight: 600;
cursor: default;
text-transform: capitalize;
`}
>
{t(status).replace('_', ' ')}
</Box>
);
};
export const Tag = ({ ...props }: TagContentProps) => {
const { t } = useTranslation();
const tooltipText: Record<
NonNullable<TagContentProps['tooltipType']>,
Partial<Record<TagContentProps['status'], string>>
> = {
domain: {
pending: t('Domain pending validation by an administrator'),
enabled: t('Active domain'),
disabled: t('Disabled domain'),
failed: t('Domain error, contact an administrator'),
action_required: t(
'A configuration action from the domain manager (outside Régie) is required',
),
},
mail: {
pending: t('Email address pending validation by an administrator'),
enabled: t('Functional email address'),
failed: t('Email address error, contact an administrator'),
disabled: t('Disabled email address'),
},
};
const rawTooltip =
props.tooltipType && tooltipText[props.tooltipType]?.[props.status];
const tooltipContent = rawTooltip ? t(rawTooltip) : '';
return props.showTooltip ? (
<Tooltip content={tooltipContent} placement={props.placement || 'top'}>
<Box>
<TagContent status={props.status} />
</Box>
</Tooltip>
) : (
<TagContent status={props.status} />
);
};
+27 -9
View File
@@ -1,4 +1,4 @@
import { CSSProperties, ComponentPropsWithRef } from 'react';
import { CSSProperties, ComponentPropsWithRef, forwardRef } from 'react';
import styled from 'styled-components';
import { tokens } from '@/cunningham';
@@ -13,6 +13,8 @@ export interface TextProps extends BoxProps {
HTMLElementTagNameMap,
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>;
$elipsis?: boolean;
$isMaterialIcon?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$size?: TextSizes | (string & {});
@@ -26,6 +28,7 @@ export interface TextProps extends BoxProps {
| 'greyscale';
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
@@ -34,7 +37,8 @@ export interface TextProps extends BoxProps {
| '600'
| '700'
| '800'
| '900';
| '900'
| '1000';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
@@ -48,12 +52,26 @@ export const TextStyled = styled(Box)<TextProps>`
${({ $theme, $variation }) =>
`color: var(--c--theme--colors--${$theme}-${$variation});`}
${({ $color }) => $color && `color: ${$color};`}
${({ $elipsis }) =>
$elipsis &&
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;
export const Text = ({
...props
}: ComponentPropsWithRef<typeof TextStyled>) => {
return (
<TextStyled as="span" $theme="greyscale" $variation="text" {...props} />
);
};
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, $isMaterialIcon, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
},
);
Text.displayName = 'Text';
export { Text };
@@ -20,8 +20,7 @@ export const TextErrors = ({
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
$margin={{ top: 'small' }}
$theme="danger"
$color="red"
$textAlign="center"
{...textProps}
>
@@ -1,51 +0,0 @@
import { render, screen } from '@testing-library/react';
import { Box } from '../Box';
describe('<Box />', () => {
it('has the padding from prop', () => {
const { unmount } = render(<Box $padding="10px">My Box</Box>);
expect(screen.getByText('My Box')).toHaveStyle('padding: 10px');
unmount();
render(
<Box $padding={{ horizontal: 'xl', all: 'large', bottom: 'tiny' }}>
My Box
</Box>,
);
expect(screen.getByText('My Box')).toHaveStyle(`
padding-left: 4rem;
padding-right: 4rem;
padding-top: 3rem;
padding-bottom: 0.5rem;`);
});
it('has the margin from prop', () => {
const { unmount } = render(<Box $margin="10px">My Box</Box>);
expect(screen.getByText('My Box')).toHaveStyle('margin: 10px');
unmount();
render(
<Box
$margin={{
horizontal: 'auto',
vertical: 'big',
bottom: 'full',
all: 'xtiny',
}}
>
My Box
</Box>,
);
expect(screen.getByText('My Box')).toHaveStyle(`
margin-left: auto;
margin-right: auto;
margin-top: 1.625rem;
margin-bottom: 100%;`);
});
});
@@ -1,80 +0,0 @@
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');
});
});
});
@@ -1,9 +1,13 @@
export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './Effect';
export * from './DropButton';
export * from './IconOptions';
export * from './Icon';
export * from './Input';
export * from './Link';
export * from './LogoGouv';
export * from './Tag';
export * from './Text';
export * from './TextErrors';
export * from './separators';
@@ -0,0 +1,75 @@
import {
Modal as CunninghamModal,
ModalProps,
} from '@openfun/cunningham-react';
import React, { useEffect } from 'react';
import { HorizontalSeparator } from '@/components';
import style from './custom-modal.module.scss';
export const CustomModal: React.FC<
ModalProps & { step?: number; totalSteps?: number }
> = ({ children, step = 0, totalSteps = 1, ...props }) => {
// Apply the hook here once for all modals
usePreventFocusVisible(['.c__modal__content']);
return (
<CunninghamModal {...props}>
{/*<div className={style.modalContainer} onClick={(e) => e.stopPropagation()}> */}
{/* modal header */}
{totalSteps > 1 && (
<div className={style.header}>
<div className={style.progressBar}>
{Array.from({ length: totalSteps }).map((_, index) => (
<div
key={index}
className={`${style.progressBarStep} ${index <= step ? style.active : ''}`}
></div>
))}
</div>
</div>
)}
<HorizontalSeparator $withPadding={true}></HorizontalSeparator>
<div>
{children}
{/* modal content */}
{/*<div className={style.content}>{children}</div>*/}
{/* modal footer */}
</div>
<div className={style.footer}></div>
{/*</div>*/}
</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/suitenumerique/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,79 @@
.modalCustom {
padding: 0;
border-radius: 4px;
box-shadow: 0 6px 18px rgba(0, 0, 18, 0.16);
}
.header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 var(--c--theme--spacings--md);
padding-top: var(--c--theme--spacings--sm);
padding-bottom: var(--c--theme--spacings--2xs);
}
.closeButton {
position: absolute;
display: block;
right: 10px;
top: 5px;
background: none;
border: none;
font-size: 34px;
cursor: pointer;
color: var(--c--theme--colors--greyscale-700);
}
.content {
padding: var(--c--theme--spacings--md);
}
.c__modal__footer {
padding: 0 var(--c--theme--spacings--md);
flex-direction: column;
justify-content: space-between;
}
.footer {
display: flex;
justify-content: space-between;
padding: 0 var(--c--theme--spacings--md);
padding-top: 0;
align-items: center;
border-top: 1px solid var(--c--theme--colors--greyscale-200);
}
.progressBar {
display: flex;
width: 100%;
padding-top: 0;
align-items: stretch;
align-content: stretch;
flex-wrap: nowrap;
height: 4px;
gap: var(--c--theme--spacings--2xs);
}
.progressBarStep {
flex: 1;
height: 4px;
display: block;
width: auto;
flex-grow: 1;
border-radius: 4px;
background-color: var(--c--theme--colors--primary-200);
&.active {
background-color: var(--c--theme--colors--primary-800);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@@ -0,0 +1,33 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
$withPadding?: boolean;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
$withPadding = true,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$height="1px"
$width="100%"
$margin={{ vertical: $withPadding ? 'base' : 'none' }}
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens()['greyscale-100']
}
/>
);
};
@@ -0,0 +1,31 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
showSeparator?: boolean;
};
export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
return (
<Box
$css={css`
width: 100%;
${showSeparator &&
css`
border-bottom: 1px solid ${colors?.['greyscale-200']};
`}
`}
>
{children}
</Box>
);
};
@@ -0,0 +1,2 @@
export * from './HorizontalSeparator';
export * from './SeparatedSection';
@@ -1,31 +0,0 @@
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { Footer } from '@/features/footer/Footer';
import { HEADER_HEIGHT, Header } from '@/features/header';
import { Menu } from '@/features/menu';
import { useConfigStore } from './config';
export function MainLayout({ children }: PropsWithChildren) {
const { config } = useConfigStore();
return (
<Box>
<Box $height="100vh">
<Header />
<Box $css="flex: 1;" $direction="row">
{config?.FEATURES.TEAMS_DISPLAY && <Menu />}
<Box
as="main"
$height={`calc(100vh - ${HEADER_HEIGHT})`}
$width="100%"
>
{children}
</Box>
</Box>
</Box>
<Footer />
</Box>
);
}
@@ -1,17 +0,0 @@
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { Footer } from '@/features/footer/Footer';
import { Header } from '@/features/header';
export function PageLayout({ children }: PropsWithChildren) {
return (
<Box>
<Header />
<Box as="main" $width="100%">
{children}
</Box>
<Footer />
</Box>
);
}

Some files were not shown because too many files have changed in this diff Show More