Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24e56a4d33 | |||
| 10dcdfc8c2 | |||
| f30398fbc7 | |||
| cc39ed5298 | |||
| 7bebf13d88 | |||
| e64a34f167 | |||
| 3379d6d499 | |||
| 213656fc2e | |||
| 4dfd682cb6 | |||
| 160c45bf35 | |||
| 3fdd8a230c | |||
| b47246826e | |||
| 5429354261 | |||
| 86c98cc426 | |||
| 0bbce9ffc8 | |||
| ce66645294 | |||
| 485eb88dd1 | |||
| a8b08c4b6d | |||
| 23f5a13ccc | |||
| 1245c54c61 | |||
| 95f63fa56d | |||
| 4c3891047b | |||
| fce9b1e490 | |||
| 83bec33bdb | |||
| 8fed2606d6 | |||
| c7eb86eaa9 | |||
| be1513e106 | |||
| 8e85f303ec | |||
| ee564ff6ba | |||
| b0355059b7 | |||
| 6e792986be | |||
| fe9fb67fed | |||
| 91fbef9066 | |||
| 4f3c9abe62 | |||
| bd43e4620d | |||
| 8c67d4a004 | |||
| 78cb3e693c | |||
| cec8e87edd | |||
| 35a700d522 | |||
| c786ddbb82 | |||
| cb198a9d04 | |||
| 3d9645b561 | |||
| 21cbeded18 | |||
| f0c609ef0b | |||
| aa3d90b686 | |||
| 4a08a9ec92 | |||
| 5544a40f5f | |||
| e274c309cd | |||
| 560998083d | |||
| 2d56c57102 | |||
| b5d86967ff | |||
| 08559d856d | |||
| 8b17a5470d | |||
| 141c4e7f61 | |||
| a5473f62b7 | |||
| 889291c7f3 | |||
| a8d20bacb0 | |||
| c4a81cf76a | |||
| 0a241f0e03 | |||
| b389927653 | |||
| 056a4bd7ac | |||
| 6721328b2d | |||
| ab5d8c74d8 | |||
| 4c14f967b6 | |||
| b42cd483c6 | |||
| 3735b699cc | |||
| 0220875c70 | |||
| 7a1fc6b626 | |||
| 99d7b23dc9 | |||
| 1a52cd63bf | |||
| 8691f1846d | |||
| edbf77c525 | |||
| 140d099fce |
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
+38
-1
@@ -8,6 +8,41 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- 🐛(front) add teams ui V2
|
||||
- 🐛(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
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(drf) disable browsable HTML API renderer #897
|
||||
|
||||
## [1.15.0] - 2025-04-04
|
||||
|
||||
### Added
|
||||
@@ -370,7 +405,9 @@ and this project adheres to
|
||||
- ✨(domains) create and manage domains on admin + API
|
||||
- ✨(domains) mailbox creation + link to email provisioning API
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/people/compare/v1.15.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
|
||||
[1.14.0]: https://github.com/suitenumerique/people/releases/v1.14.0
|
||||
|
||||
-55
@@ -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,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
|
||||
|
||||
recreate-dimail-container:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
.PHONY: recreate-dimail-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
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Security is very important to us.
|
||||
|
||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at support-regie@numerique.gouv.fr
|
||||
|
||||
We appreciate your effort to make People more secure.
|
||||
|
||||
## Vulnerability disclosure policy
|
||||
|
||||
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
|
||||
|
||||
1. The Maintainers team will handle the fix as usual (Pull Request,
|
||||
release).
|
||||
2. In the release notes, we will include the identification numbers from the
|
||||
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
|
||||
and Exposures (CVE) identifier for the vulnerability.
|
||||
3. Once this grace period has passed, we will publish the vulnerability.
|
||||
|
||||
By adhering to this security policy, we aim to address security concerns
|
||||
effectively and responsibly in our open source software project.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"oauth2DeviceCodeLifespan": 600,
|
||||
"oauth2DevicePollingInterval": 5,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"email": "user-e2e-chromium@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
@@ -97,7 +97,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
"email": "user-e2e-webkit@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
"email": "user-e2e-firefox@example.org",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": true,
|
||||
|
||||
@@ -2,44 +2,49 @@
|
||||
|
||||
## What is dimail ?
|
||||
|
||||
The mailing solution provided in La Suite is [Open-XChange](https://www.open-xchange.com/) (OX).
|
||||
OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
|
||||
The mailing solution provided in La Suite is [La Messagerie](https://webmail.numerique.gouv.fr/), using [Open-XChange](https://www.open-xchange.com/) (OX). OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
|
||||
|
||||
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
|
||||
|
||||
## Architectural links of dimail
|
||||
## Use of dimail container
|
||||
|
||||
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users, permissions and mailboxes.
|
||||
To ease local development, dimail provides a container that we embark in our docker-compose. In "FAKE" mode, it simulates all responses from Open Exchange.
|
||||
|
||||
Bootstraping with command `make bootstrap` creates a container and initializes its database.
|
||||
|
||||
Additional commands :
|
||||
- Reset and populate the database with all the content of your People database with `dimail-setup-db`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Domains
|
||||
|
||||
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered enabled.
|
||||
|
||||
Domains in OX have a field called "context". Context is a shared space between domains, allowing users to discover users not only on their domain but on their entire context.
|
||||
Domains in OX have a field called "context". "Contexts" are shared spaces between domains, allowing users to discover users not only on their domain but on their entire context.
|
||||
> [!NOTE]
|
||||
> Contexts are only implemented in La Messagerie and are not currently in use in People. Domains created via People are in their own context.
|
||||
|
||||
People users can have 3 levels of permissions on a domain:
|
||||
- Viewers can
|
||||
- see the domain's information
|
||||
- list its mailboxes and managers
|
||||
- Administrators can
|
||||
- create mailboxes
|
||||
- invite collaborators to manage domain
|
||||
- change role of a viewer to administrators
|
||||
- all of viewers permissions
|
||||
- Owners can
|
||||
- promote administrators owners and demote owners
|
||||
- all of viewers and administrators' permissions
|
||||
> [!NOTE]
|
||||
> Contexts are only implemented in La Messagerie and are not currently in use in People. Domains created via People are in their own context.
|
||||
|
||||
|
||||
### Mailboxes
|
||||
|
||||
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.
|
||||
|
||||
### Users
|
||||
|
||||
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
|
||||
|
||||
### Permissions
|
||||
|
||||
As for People, an access - a permissions (or an "allow" in dimail) - grants an user permission to create objects on a domain.
|
||||
|
||||
Permissions requests are sent automatically upon :
|
||||
- dimail database initialisation:
|
||||
+ permission for dimail user People to create users and domains
|
||||
- domain creation :
|
||||
+ permission for dimail user People to manage domain
|
||||
+ permission for new owner on new domain
|
||||
- user creation:
|
||||
+ permission for People to manage user
|
||||
- access creation, if owner or admin:
|
||||
+ permission for this user to manage domain
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint", "@hookform/resolvers"]
|
||||
},
|
||||
{
|
||||
"groupName": "docker-compose dependencies",
|
||||
"matchManagers": ["docker-compose"],
|
||||
"matchPackageNames": ["dimail-api"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+149
@@ -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"
|
||||
+5
-2
@@ -107,6 +107,9 @@ Continue ? (y,n)
|
||||
run_command(f"git push origin {deployment_branch}", shell=True)
|
||||
sys.stdout.write(f"""\033[1;34m
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Check if you need to bump chart version in the manifests/regie/helmfile.yaml file
|
||||
and settings in the manifests/regie/env.d/preprod/values.desk.yaml.gotmpl file
|
||||
add commits into {deployment_branch} branch to do it if needed, then...
|
||||
--> Please submit PR {deployment_branch} and merge code to main [https://github.com/numerique-gouv/lasuite-deploiement]
|
||||
\x1b[0m""")
|
||||
|
||||
@@ -137,8 +140,8 @@ Continue ? (y,n)
|
||||
run_command(f"git push origin {branch_to_release}", shell=True)
|
||||
sys.stdout.write(f"""
|
||||
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> A github action will download last translations from crowdin and create a commit to merge into the release branch.
|
||||
Please wait for this and merge this translations commit into the release branch {branch_to_release}.
|
||||
--> A github action will download last translations from crowdin and create a PR (i18n/update-translations) to merge into the release branch.
|
||||
Please wait for this, approve and merge this translations PR into the release branch {branch_to_release}.
|
||||
--> Then please submit PR {branch_to_release} and merge code to main [https://github.com/suitenumerique/people/]
|
||||
--> Then do:
|
||||
>> git checkout main
|
||||
|
||||
@@ -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)
|
||||
@@ -49,13 +49,14 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
)
|
||||
return extra_claims
|
||||
|
||||
def post_get_or_create_user(self, user, claims):
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
@@ -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,18 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-10 07:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_accountservice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='accountservice',
|
||||
name='api_key',
|
||||
field=models.CharField(max_length=255, verbose_name='api key'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
+39
-17
@@ -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,
|
||||
@@ -1107,7 +1122,6 @@ class AccountService(BaseModel):
|
||||
api_key = models.CharField(
|
||||
_("api key"),
|
||||
max_length=255,
|
||||
default=secrets.token_urlsafe(32),
|
||||
)
|
||||
scopes = ArrayField(
|
||||
models.CharField(max_length=255, validators=[validate_account_service_scope]),
|
||||
@@ -1123,6 +1137,14 @@ class AccountService(BaseModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save method to generate a new api_key if it is not set.
|
||||
"""
|
||||
if not self.api_key:
|
||||
self.api_key = secrets.token_urlsafe(32)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
"""Indicate if the account service is authenticated."""
|
||||
|
||||
@@ -257,7 +257,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
match="Claims verification failed",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
@@ -411,7 +411,7 @@ def test_authentication_getter_existing_user_via_email_update_organization(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(9):
|
||||
with django_assert_num_queries(11):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
@@ -52,8 +52,8 @@ def test_api_contacts_update_authenticated_owned(django_assert_num_queries):
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
with django_assert_num_queries(8):
|
||||
# user, 2x contact, user, 3x check, update contact
|
||||
with django_assert_num_queries(14):
|
||||
# user, 2x contact, user, 3x check, update contact, 3x savepoint/release
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
"""Test fixtures."""
|
||||
+75
@@ -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": {
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_models_team_accesses_unique():
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Team/user relation with this User and Team already exists.",
|
||||
match="This user is already in this team.",
|
||||
):
|
||||
factories.TeamAccessFactory(user=access.user, team=access.team)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-04 08:20+0000\n"
|
||||
"PO-Revision-Date: 2025-04-04 15:29\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"
|
||||
@@ -45,24 +45,24 @@ msgstr ""
|
||||
msgid "People core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:103
|
||||
#: core/authentication/backends.py:103
|
||||
#: build/lib/core/authentication/backends.py:104
|
||||
#: core/authentication/backends.py:104
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:123
|
||||
#: core/authentication/backends.py:123
|
||||
#: build/lib/core/authentication/backends.py:124
|
||||
#: core/authentication/backends.py:124
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:180
|
||||
#: build/lib/core/authentication/backends.py:182
|
||||
#: core/authentication/backends.py:180 core/authentication/backends.py:182
|
||||
#: build/lib/core/authentication/backends.py:181
|
||||
#: build/lib/core/authentication/backends.py:183
|
||||
#: core/authentication/backends.py:181 core/authentication/backends.py:183
|
||||
msgid "Invalid authorization header."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:187
|
||||
#: core/authentication/backends.py:187
|
||||
#: build/lib/core/authentication/backends.py:188
|
||||
#: core/authentication/backends.py:188
|
||||
msgid "Invalid api key."
|
||||
msgstr ""
|
||||
|
||||
@@ -337,24 +337,24 @@ msgstr ""
|
||||
msgid "api key"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1114 core/models.py:1114
|
||||
#: build/lib/core/models.py:1113 core/models.py:1113
|
||||
msgid "allowed scopes"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1115 core/models.py:1115
|
||||
#: build/lib/core/models.py:1114 core/models.py:1114
|
||||
msgid "Allowed scopes for this service"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1120 core/models.py:1120
|
||||
#: build/lib/core/models.py:1119 core/models.py:1119
|
||||
msgid "Account service"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1121 core/models.py:1121
|
||||
#: build/lib/core/models.py:1120 core/models.py:1120
|
||||
msgid "Account services"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
|
||||
msgid "Synchronise from dimail"
|
||||
msgid "Import emails from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
|
||||
@@ -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:266
|
||||
#: mailbox_manager/utils/dimail.py:266
|
||||
#: 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:157 people/settings.py:157
|
||||
#: 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:158 people/settings.py:158
|
||||
#: 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.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-04 08:20+0000\n"
|
||||
"PO-Revision-Date: 2025-04-04 15:29\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,28 +43,28 @@ 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:103
|
||||
#: core/authentication/backends.py:103
|
||||
#: build/lib/core/authentication/backends.py:104
|
||||
#: core/authentication/backends.py:104
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:123
|
||||
#: core/authentication/backends.py:123
|
||||
#: build/lib/core/authentication/backends.py:124
|
||||
#: core/authentication/backends.py:124
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:180
|
||||
#: build/lib/core/authentication/backends.py:182
|
||||
#: core/authentication/backends.py:180 core/authentication/backends.py:182
|
||||
#: build/lib/core/authentication/backends.py:181
|
||||
#: 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:187
|
||||
#: core/authentication/backends.py:187
|
||||
#: 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 "périmètres autorisés"
|
||||
|
||||
#: build/lib/core/models.py:1114 core/models.py:1114
|
||||
msgid "allowed scopes"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1115 core/models.py:1115
|
||||
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 "Compte de service"
|
||||
|
||||
#: build/lib/core/models.py:1120 core/models.py:1120
|
||||
msgid "Account service"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1121 core/models.py:1121
|
||||
msgid "Account services"
|
||||
msgstr ""
|
||||
msgstr "Comptes de service"
|
||||
|
||||
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
|
||||
msgid "Synchronise from dimail"
|
||||
msgstr "Synchroniser à partir de dimail"
|
||||
msgid "Import emails from dimail"
|
||||
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:266
|
||||
#: mailbox_manager/utils/dimail.py:266
|
||||
#: 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:157 people/settings.py:157
|
||||
#: 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:158 people/settings.py:158
|
||||
#: 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
|
||||
|
||||
@@ -14,7 +14,7 @@ from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
# ruff: noqa: S308
|
||||
|
||||
|
||||
@admin.action(description=_("Synchronise from dimail"))
|
||||
@admin.action(description=_("Import emails from dimail"))
|
||||
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Admin action to synchronize existing mailboxes from dimail to our database.
|
||||
Only works on enabled domains."""
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -53,12 +53,19 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
mailbox.status = enums.MailDomainStatusChoices.ENABLED
|
||||
mailbox.save()
|
||||
|
||||
# send confirmation email
|
||||
client.notify_mailbox_creation(
|
||||
recipient=mailbox.secondary_email,
|
||||
mailbox_data=response.json(),
|
||||
issuer=self.context["request"].user,
|
||||
)
|
||||
if mailbox.secondary_email:
|
||||
# send confirmation email
|
||||
client.notify_mailbox_creation(
|
||||
recipient=mailbox.secondary_email,
|
||||
mailbox_data=response.json(),
|
||||
issuer=self.context["request"].user,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Email notification for %s creation not sent "
|
||||
"because no secondary email found",
|
||||
mailbox,
|
||||
)
|
||||
|
||||
# actually save mailbox on our database
|
||||
return mailbox
|
||||
@@ -228,29 +235,6 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Override create function to fire requests to dimail on access creation.
|
||||
"""
|
||||
dimail = DimailAPIClient()
|
||||
|
||||
user = validated_data["user"]
|
||||
domain = validated_data["domain"]
|
||||
|
||||
if validated_data["role"] in [
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
]:
|
||||
try:
|
||||
dimail.create_user(user.sub)
|
||||
dimail.create_allow(user.sub, domain.name)
|
||||
except HTTPError:
|
||||
logger.exception("[DIMAIL] access creation failed %s")
|
||||
domain.status = enums.MailDomainStatusChoices.FAILED
|
||||
domain.save()
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
|
||||
"""Serialize mail domain access for list and retrieve actions."""
|
||||
|
||||
@@ -241,7 +241,7 @@ class MailBoxViewSet(
|
||||
- first_name: str
|
||||
- last_name: str
|
||||
- local_part: str
|
||||
- secondary_email: str
|
||||
- secondary_email: str (optional)
|
||||
Sends request to email provisioning API and returns newly created mailbox
|
||||
|
||||
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/disable/
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-08 20:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0024_domaininvitation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='mailbox',
|
||||
name='secondary_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='secondary email address'),
|
||||
),
|
||||
]
|
||||
@@ -284,7 +284,7 @@ class Mailbox(AbstractBaseUser, BaseModel):
|
||||
blank=False,
|
||||
)
|
||||
secondary_email = models.EmailField(
|
||||
_("secondary email address"), null=False, blank=False
|
||||
_("secondary email address"), null=True, blank=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
|
||||
@@ -12,9 +12,7 @@ from django.utils import timezone
|
||||
|
||||
from core.models import User
|
||||
|
||||
from mailbox_manager import enums
|
||||
from mailbox_manager.models import MailDomainAccess, MailDomainInvitation
|
||||
from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,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,
|
||||
@@ -38,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(
|
||||
@@ -47,21 +49,4 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
|
||||
]
|
||||
)
|
||||
|
||||
management_role = set(valid_domain_invitations.values_list("role", flat="True"))
|
||||
if (
|
||||
enums.MailDomainRoleChoices.OWNER in management_role
|
||||
or enums.MailDomainRoleChoices.ADMIN in management_role
|
||||
):
|
||||
# Sync with dimail
|
||||
dimail = DimailAPIClient()
|
||||
dimail.create_user(instance.sub)
|
||||
|
||||
for invitation in valid_domain_invitations:
|
||||
if invitation.role in [
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
]:
|
||||
dimail.create_allow(instance.sub, invitation.domain.name)
|
||||
|
||||
valid_domain_invitations.delete()
|
||||
logger.info("Invitations converted to domain accesses for user %s", instance)
|
||||
|
||||
+23
@@ -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."
|
||||
]
|
||||
|
||||
@@ -82,27 +82,6 @@ def test_api_mail_domains__create_authenticated():
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=str(
|
||||
{
|
||||
"name": "request-user-sub",
|
||||
"is_admin": "false",
|
||||
"uuid": "user-uuid-on-dimail",
|
||||
"perms": [],
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
|
||||
body_content_domain1["name"] = domain_name
|
||||
responses.add(
|
||||
@@ -166,101 +145,6 @@ def test_api_mail_domains__create_authenticated():
|
||||
assert domain.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
|
||||
"""
|
||||
Despite a dimail failure for user and/or allow creation,
|
||||
an authenticated user should be able to create a mail domain
|
||||
and should automatically be added as owner of the newly created domain.
|
||||
"""
|
||||
caplog.set_level(logging.ERROR)
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
domain_name = "test.domain.fr"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/domains/"),
|
||||
body=str(
|
||||
{
|
||||
"name": domain_name,
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=str(
|
||||
{
|
||||
"name": "request-user-sub",
|
||||
"is_admin": "false",
|
||||
"uuid": "user-uuid-on-dimail",
|
||||
"perms": [],
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
content_type="application/json",
|
||||
)
|
||||
dimail_error = {
|
||||
"status_code": status.HTTP_401_UNAUTHORIZED,
|
||||
"detail": "Not authorized",
|
||||
}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(rf".*/domains/{domain_name}/check/"),
|
||||
body=json.dumps(dimail_error),
|
||||
status=dimail_error["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": domain_name,
|
||||
"context": "null",
|
||||
"features": ["webmail"],
|
||||
"support_email": f"support@{domain_name}",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
domain = models.MailDomain.objects.get()
|
||||
|
||||
# response is as expected
|
||||
assert response.json() == {
|
||||
"id": str(domain.id),
|
||||
"name": domain.name,
|
||||
"slug": domain.slug,
|
||||
"status": enums.MailDomainStatusChoices.FAILED,
|
||||
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"abilities": domain.get_abilities(user),
|
||||
"count_mailboxes": 0,
|
||||
"support_email": domain.support_email,
|
||||
"last_check_details": None,
|
||||
"action_required_details": {},
|
||||
"expected_config": None,
|
||||
}
|
||||
|
||||
# a new domain with status "failed" is created and authenticated user is the owner
|
||||
assert domain.status == enums.MailDomainStatusChoices.FAILED
|
||||
assert domain.name == domain_name
|
||||
assert domain.accesses.filter(role="owner", user=user).exists()
|
||||
assert caplog.records[0].levelname == "ERROR"
|
||||
assert "Not authorized" in caplog.records[0].message
|
||||
|
||||
|
||||
## SYNC TO DIMAIL
|
||||
@responses.activate
|
||||
def test_api_mail_domains__create_dimail_domain(caplog):
|
||||
@@ -285,28 +169,6 @@ def test_api_mail_domains__create_dimail_domain(caplog):
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=str(
|
||||
{
|
||||
"name": "request-user-sub",
|
||||
"is_admin": "false",
|
||||
"uuid": "user-uuid-on-dimail",
|
||||
"perms": [],
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
body_content_domain1 = CHECK_DOMAIN_OK.copy()
|
||||
body_content_domain1["name"] = domain_name
|
||||
responses.add(
|
||||
@@ -340,11 +202,6 @@ def test_api_mail_domains__create_dimail_domain(caplog):
|
||||
f"Domain {domain_name} successfully created on dimail by user {user.sub}"
|
||||
in log_messages
|
||||
)
|
||||
assert f'[DIMAIL] User "{user.sub}" successfully created on dimail' in log_messages
|
||||
assert (
|
||||
f'[DIMAIL] Permissions granted for user "{user.sub}" on domain {domain_name}.'
|
||||
in log_messages
|
||||
)
|
||||
|
||||
|
||||
@responses.activate
|
||||
@@ -359,27 +216,6 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
|
||||
"status_code": status.HTTP_409_CONFLICT,
|
||||
"detail": "Domain already exists",
|
||||
}
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=str(
|
||||
{
|
||||
"name": "request-user-sub",
|
||||
"is_admin": "false",
|
||||
"uuid": "user-uuid-on-dimail",
|
||||
"perms": [],
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(r".*/domains/"),
|
||||
|
||||
+26
-219
@@ -2,12 +2,9 @@
|
||||
Test for mail domain accesses API endpoints in People's core app : create
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -108,16 +105,15 @@ def test_api_mail_domain__accesses_create_authenticated_administrator():
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": enums.MailDomainRoleChoices.OWNER,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": enums.MailDomainRoleChoices.OWNER,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
@@ -127,38 +123,14 @@ def test_api_mail_domain__accesses_create_authenticated_administrator():
|
||||
# It should be allowed to create a lower access
|
||||
for role in [enums.MailDomainRoleChoices.ADMIN, enums.MailDomainRoleChoices.VIEWER]:
|
||||
other_user = core_factories.UserFactory()
|
||||
with responses.RequestsMock() as rsps:
|
||||
if role != enums.MailDomainRoleChoices.VIEWER:
|
||||
# viewers don't have allows in dimail
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=str(
|
||||
{
|
||||
"name": str(other_user.sub),
|
||||
"is_admin": "false",
|
||||
"uuid": "71f60d74-a3ad-46bc-bc2b-20d79a2e36fb",
|
||||
"perms": [],
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=str({"user": other_user.sub, "domain": str(mail_domain.name)}),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
new_mail_domain_access = models.MailDomainAccess.objects.filter(
|
||||
user=other_user
|
||||
@@ -182,37 +154,15 @@ def test_api_mail_domain__accesses_create_authenticated_owner():
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
with responses.RequestsMock() as rsps:
|
||||
if role != enums.MailDomainRoleChoices.VIEWER:
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=str(
|
||||
{
|
||||
"name": str(other_user.sub),
|
||||
"is_admin": "false",
|
||||
"uuid": "71f60d74-a3ad-46bc-bc2b-20d79a2e36fb",
|
||||
"perms": [],
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=str({"user": other_user.sub, "domain": str(mail_domain.name)}),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert models.MailDomainAccess.objects.filter(user=other_user).count() == 1
|
||||
@@ -221,146 +171,3 @@ def test_api_mail_domain__accesses_create_authenticated_owner():
|
||||
).get()
|
||||
assert response.json()["id"] == str(new_mail_domain_access.id)
|
||||
assert response.json()["role"] == role
|
||||
|
||||
|
||||
## INTEROP WITH DIMAIL
|
||||
def test_api_mail_domains_accesses__create_dimail_allows(caplog):
|
||||
"""
|
||||
Creating a domain access on our API should trigger a request to create an access on dimail too.
|
||||
"""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
domain = factories.MailDomainFactory(status="enabled")
|
||||
factories.MailDomainAccessFactory(
|
||||
domain=domain, user=authenticated_user, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
|
||||
allowed_user = core_factories.UserFactory()
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=str(
|
||||
{
|
||||
"name": str(allowed_user.sub),
|
||||
"is_admin": "false",
|
||||
"uuid": "71f60d74-a3ad-46bc-bc2b-20d79a2e36fb",
|
||||
"perms": [],
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=str({"user": allowed_user.sub, "domain": str(domain.name)}),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(allowed_user.id),
|
||||
"role": enums.MailDomainRoleChoices.ADMIN,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
# check logs
|
||||
assert (
|
||||
f'[DIMAIL] User "{allowed_user.sub}" successfully created on dimail'
|
||||
in log_messages
|
||||
)
|
||||
assert (
|
||||
f'[DIMAIL] Permissions granted for user "{allowed_user.sub}" on domain {domain.name}.'
|
||||
in log_messages
|
||||
)
|
||||
|
||||
|
||||
def test_api_mail_domains_accesses__dont_create_dimail_allows_for_viewer():
|
||||
"""Dimail should not be called when creating an access to a simple viewer."""
|
||||
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
domain = factories.MailDomainFactory(status="enabled")
|
||||
factories.MailDomainAccessFactory(
|
||||
domain=domain, user=authenticated_user, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
|
||||
allowed_user = core_factories.UserFactory()
|
||||
with responses.RequestsMock():
|
||||
# No call expected
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(allowed_user.id),
|
||||
"role": enums.MailDomainRoleChoices.VIEWER,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
|
||||
def test_api_mail_domains_accesses__user_already_on_dimail(caplog):
|
||||
"""The expected allow should be created when an user already exists on dimail
|
||||
(i.e. previous admin/owner of same domain or current on another domain)."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
domain = factories.MailDomainFactory()
|
||||
factories.MailDomainAccessFactory(
|
||||
domain=domain, user=authenticated_user, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
|
||||
allowed_user = core_factories.UserFactory()
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# No call expected
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=str(
|
||||
{"detail": "User already exists"}
|
||||
), # the user is already on dimail
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=str({"user": allowed_user.sub, "domain": str(domain.name)}),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/accesses/",
|
||||
{
|
||||
"user": str(allowed_user.id),
|
||||
"role": enums.MailDomainRoleChoices.ADMIN,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# check logs
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
assert (
|
||||
f'[DIMAIL] Attempt to create user "{allowed_user.sub}" which already exists.'
|
||||
in log_messages
|
||||
)
|
||||
assert (
|
||||
f'[DIMAIL] Permissions granted for user "{allowed_user.sub}" on domain {domain.name}.'
|
||||
in log_messages
|
||||
)
|
||||
|
||||
@@ -197,7 +197,7 @@ def test_api_mailboxes__create_with_accent_success(role):
|
||||
def test_api_mailboxes__create_administrator_missing_fields():
|
||||
"""
|
||||
Administrator users should not be able to create mailboxes
|
||||
without local part or secondary mail.
|
||||
without local part.
|
||||
"""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
access = factories.MailDomainAccessFactory(
|
||||
@@ -219,18 +219,54 @@ def test_api_mailboxes__create_administrator_missing_fields():
|
||||
assert not models.Mailbox.objects.exists()
|
||||
assert response.json() == {"local_part": ["This field is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN],
|
||||
)
|
||||
def test_api_mailboxes__create_without_secondary_email(role, caplog):
|
||||
"""
|
||||
Creating a new mailbox should not require a secondary email.
|
||||
We should be able to create a mailbox but not send any email notification.
|
||||
"""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
del mailbox_values["secondary_email"]
|
||||
|
||||
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/{mail_domain.name}/mailboxes/"),
|
||||
body=response_mailbox_created(
|
||||
f"{mailbox_values['local_part']}@{mail_domain.name}"
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not models.Mailbox.objects.exists()
|
||||
assert response.json() == {"secondary_email": ["This field is required."]}
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
mailbox = models.Mailbox.objects.get()
|
||||
assert (
|
||||
caplog.records[0].message
|
||||
== f"Email notification for {mailbox} creation not sent "
|
||||
"because no secondary email found"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -514,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)
|
||||
@@ -729,8 +765,7 @@ def test_api_mailboxes__handling_dimail_unexpected_error(caplog):
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_api_mailboxes__send_correct_logger_infos(mock_info, mock_error):
|
||||
"""
|
||||
Upon requesting mailbox creation, la régie should impersonate
|
||||
querying user in dimail and log things correctly.
|
||||
Upon requesting mailbox creation, logs should report request user.
|
||||
"""
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
|
||||
@@ -766,14 +801,11 @@ def test_api_mailboxes__send_correct_logger_infos(mock_info, mock_error):
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# user sub is sent to payload as a parameter
|
||||
assert rsps.calls[0].request.params == {"username": access.user.sub}
|
||||
|
||||
# Logger
|
||||
assert not mock_error.called
|
||||
# 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
@@ -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"})
|
||||
|
||||
@@ -2,21 +2,17 @@
|
||||
Unit tests for the Mail Domain Invitation model
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import exceptions
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories, models
|
||||
from mailbox_manager.tests.fixtures import dimail
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -42,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(
|
||||
@@ -63,41 +63,30 @@ def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_j
|
||||
domain=invitation_to_domain2.domain
|
||||
)
|
||||
|
||||
new_user = core_factories.UserFactory.build(email=email)
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/users/"),
|
||||
body=dimail.response_user_created("sub"),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/allows/"),
|
||||
body=dimail.response_allows_created(
|
||||
"sub", invitation_to_domain1.domain.name
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
new_user = core_factories.UserFactory(email=email)
|
||||
new_user = core_factories.UserFactory(email=email)
|
||||
|
||||
assert models.MailDomainAccess.objects.filter(
|
||||
domain=invitation_to_domain1.domain, user=new_user
|
||||
).exists()
|
||||
assert not models.MailDomainInvitation.objects.filter(
|
||||
domain=invitation_to_domain1.domain, email=email
|
||||
).exists() # invitation "consumed"
|
||||
|
||||
assert models.MailDomainAccess.objects.filter(
|
||||
domain=invitation_to_domain2.domain, user=new_user
|
||||
).exists()
|
||||
assert not models.MailDomainInvitation.objects.filter(
|
||||
domain=invitation_to_domain1.domain, email=email
|
||||
).exists() # invitation "consumed"
|
||||
assert not models.MailDomainInvitation.objects.filter(
|
||||
domain=invitation_to_domain2.domain, email=email
|
||||
).exists() # invitation "consumed"
|
||||
|
||||
assert models.MailDomainInvitation.objects.filter(
|
||||
domain=expired_invitation.domain, email=email
|
||||
).exists() # expired invitation remains
|
||||
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
|
||||
)
|
||||
|
||||
@@ -81,18 +81,6 @@ def test_models_mailboxes__domain_cannot_be_null():
|
||||
# SECONDARY_EMAIL FIELD
|
||||
|
||||
|
||||
def test_models_mailboxes__secondary_email_cannot_be_empty():
|
||||
"""The "secondary_email" field should not be empty."""
|
||||
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
|
||||
factories.MailboxFactory(secondary_email="")
|
||||
|
||||
|
||||
def test_models_mailboxes__secondary_email_cannot_be_null():
|
||||
"""The "secondary_email" field should not be null."""
|
||||
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
|
||||
factories.MailboxFactory(secondary_email=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain_status",
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
"""A minimalist client to synchronize with mailbox provisioning API."""
|
||||
|
||||
import ast
|
||||
@@ -44,29 +46,32 @@ class DimailAPIClient:
|
||||
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
|
||||
API_TIMEOUT = settings.MAIL_PROVISIONING_API_TIMEOUT
|
||||
|
||||
def get_headers(self, request_user=None):
|
||||
def get_headers(self):
|
||||
"""
|
||||
Build headers dictionary. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
|
||||
Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
|
||||
to get a token from dimail /token/ endpoint.
|
||||
If provided, request user' sub is used for la regie to log in on behalf of this user,
|
||||
thus allowing for more precise logs.
|
||||
"""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
params = None
|
||||
|
||||
if request_user:
|
||||
params = {"username": str(request_user)}
|
||||
|
||||
response = requests.get(
|
||||
f"{self.API_URL}/token/",
|
||||
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
|
||||
params=params,
|
||||
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["Authorization"] = f"Bearer {response.json()['access_token']}"
|
||||
logger.info("Token succesfully granted by mail-provisioning API.")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {response.json()['access_token']}",
|
||||
}
|
||||
logger.info("Token successfully granted by mail-provisioning API.")
|
||||
return headers
|
||||
|
||||
if response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
@@ -126,7 +131,7 @@ class DimailAPIClient:
|
||||
# displayName value has to be unique
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
}
|
||||
headers = self.get_headers(request_user)
|
||||
headers = self.get_headers()
|
||||
|
||||
try:
|
||||
response = session.post(
|
||||
@@ -260,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.
|
||||
@@ -334,9 +367,6 @@ class DimailAPIClient:
|
||||
last_name=dimail_mailbox["surName"],
|
||||
local_part=address.username,
|
||||
domain=domain,
|
||||
secondary_email=dimail_mailbox["email"],
|
||||
# secondary email is mandatory. Unfortunately, dimail doesn't
|
||||
# store any. We temporarily give current email as secondary email.
|
||||
status=enums.MailboxStatusChoices.ENABLED,
|
||||
password=make_password(None), # unusable password
|
||||
)
|
||||
@@ -359,7 +389,7 @@ class DimailAPIClient:
|
||||
response = session.patch(
|
||||
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}",
|
||||
json={"active": "no"},
|
||||
headers=self.get_headers(request_user),
|
||||
headers=self.get_headers(),
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
@@ -383,7 +413,7 @@ class DimailAPIClient:
|
||||
"surName": mailbox.last_name,
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
},
|
||||
headers=self.get_headers(request_user),
|
||||
headers=self.get_headers(),
|
||||
verify=True,
|
||||
timeout=self.API_TIMEOUT,
|
||||
)
|
||||
@@ -397,22 +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)
|
||||
try:
|
||||
response = self.create_mailbox(mailbox)
|
||||
except requests.exceptions.HTTPError:
|
||||
failed_mailboxes.append(str(mailbox))
|
||||
else:
|
||||
mailbox.status = enums.MailDomainStatusChoices.ENABLED
|
||||
mailbox.save()
|
||||
|
||||
mailbox.status = enums.MailDomainStatusChoices.ENABLED
|
||||
mailbox.save()
|
||||
|
||||
# send confirmation email
|
||||
self.notify_mailbox_creation(
|
||||
recipient=mailbox.secondary_email,
|
||||
mailbox_data=response.json(),
|
||||
)
|
||||
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."""
|
||||
@@ -461,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()
|
||||
@@ -563,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)
|
||||
|
||||
@@ -20,9 +20,9 @@ def test_login_view_options(client):
|
||||
assert response.status_code == 200
|
||||
assert response.headers == {
|
||||
"Content-Type": "application/json",
|
||||
"Vary": "Accept, Authorization, origin, Accept-Language, Cookie",
|
||||
"Vary": "Authorization, origin, Accept-Language, Cookie",
|
||||
"Allow": "POST, OPTIONS",
|
||||
"Content-Length": "209",
|
||||
"Content-Length": "197",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Language": "en-us",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
@@ -52,7 +52,7 @@ def test_login_view_authorize(client):
|
||||
|
||||
assert response.headers == {
|
||||
"Content-Type": "application/json",
|
||||
"Vary": "Accept, Authorization, Cookie, origin, Accept-Language",
|
||||
"Vary": "Authorization, Cookie, origin, Accept-Language",
|
||||
"Allow": "POST, OPTIONS",
|
||||
"Content-Length": "36",
|
||||
"X-Frame-Options": "DENY",
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 _
|
||||
|
||||
@@ -21,6 +22,7 @@ import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
@@ -275,6 +277,12 @@ class Base(Configuration):
|
||||
"nested_multipart_parser.drf.DrfNestedParser",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
# 🔒️ Disable BrowsableAPIRenderer which provides forms allowing a user to
|
||||
# see all the data in the database (ie a serializer with a ForeignKey field
|
||||
# will generate a form with a field with all possible values of the FK).
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"EXCEPTION_HANDLER": "core.api.exception_handler",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
@@ -392,6 +400,9 @@ class Base(Configuration):
|
||||
}
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
@@ -475,9 +486,13 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
|
||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||
default=values.ListValue( # retrocompatibility
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
|
||||
environ_prefix=None,
|
||||
),
|
||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = values.Value(
|
||||
@@ -533,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,
|
||||
@@ -579,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(
|
||||
@@ -742,7 +769,10 @@ class Base(Configuration):
|
||||
dsn=cls.SENTRY_DSN,
|
||||
environment=cls.__name__.lower(),
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
integrations=[
|
||||
DjangoIntegration(),
|
||||
CeleryIntegration(monitor_beat_tasks=True),
|
||||
],
|
||||
traces_sample_rate=0.1,
|
||||
)
|
||||
|
||||
@@ -806,6 +836,29 @@ class Development(Base):
|
||||
DEBUG = True
|
||||
|
||||
SESSION_COOKIE_NAME = "people_sessionid"
|
||||
SESSION_CACHE_ALIAS = "session"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
|
||||
},
|
||||
"session": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/2",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"TIMEOUT": values.IntegerValue(
|
||||
30, # timeout in seconds
|
||||
environ_name="CACHES_DEFAULT_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# this is a dev credentials for mail provisioning API
|
||||
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
|
||||
@@ -895,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
|
||||
|
||||
+24
-24
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "people"
|
||||
version = "1.15.0"
|
||||
version = "1.17.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -27,22 +27,23 @@ requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Brotli==1.1.0",
|
||||
"PyJWT==2.10.1",
|
||||
"boto3==1.37.24",
|
||||
"celery[redis]==5.5.0",
|
||||
"django-celery-beat==2.7.0",
|
||||
"django-celery-results==2.5.1",
|
||||
"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.1",
|
||||
"django-extensions==4.1",
|
||||
"django-lasuite==0.0.9",
|
||||
"django-oauth-toolkit==3.0.1",
|
||||
"django-parler==2.3",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages==1.14.5",
|
||||
"django-storages==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-treebeard==4.7.1",
|
||||
"django-zxcvbn-password-validator==1.4.5",
|
||||
"django==5.1.8",
|
||||
"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.25.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==3.2.3",
|
||||
"drf-spectacular-sidecar==2025.3.1",
|
||||
"drf-spectacular-sidecar==2025.6.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.0.2",
|
||||
"ipython==9.3.0",
|
||||
"jq==1.8.0",
|
||||
"pyfakefs==5.8.0",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.6",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.10.0",
|
||||
"pytest==8.3.5",
|
||||
"pylint==3.3.7",
|
||||
"pytest-cov==6.1.1",
|
||||
"pytest-django==4.11.1",
|
||||
"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.2",
|
||||
"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]
|
||||
|
||||
@@ -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 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
@@ -1,6 +1,8 @@
|
||||
server {
|
||||
listen 3000;
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
server_tokens off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-desk",
|
||||
"version": "1.15.0",
|
||||
"version": "1.17.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -15,48 +15,54 @@
|
||||
"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.71.0",
|
||||
"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.0",
|
||||
"next": "15.2.4",
|
||||
"luxon": "3.6.1",
|
||||
"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.0",
|
||||
"styled-components": "6.1.16",
|
||||
"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.3.3",
|
||||
"@hookform/devtools": "4.4.0",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.71.0",
|
||||
"@tanstack/react-query-devtools": "5.76.1",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.2.0",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.16",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/luxon": "3.6.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "19.0.12",
|
||||
"@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.17.0",
|
||||
"stylelint-config-standard": "37.0.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 },
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 |
@@ -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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user