Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24e56a4d33 | |||
| 10dcdfc8c2 | |||
| f30398fbc7 | |||
| cc39ed5298 | |||
| 7bebf13d88 | |||
| e64a34f167 | |||
| 3379d6d499 | |||
| 213656fc2e | |||
| 4dfd682cb6 |
@@ -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 }}
|
||||
|
||||
|
||||
@@ -8,6 +8,17 @@ 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
|
||||
|
||||
-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 libxml2
|
||||
|
||||
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
|
||||
@@ -187,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
|
||||
|
||||
@@ -426,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
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
+123
-39
@@ -6,6 +6,11 @@ services:
|
||||
- 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
|
||||
@@ -23,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
|
||||
@@ -37,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
|
||||
@@ -56,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}
|
||||
@@ -72,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}
|
||||
@@ -103,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
|
||||
@@ -112,12 +185,9 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- app
|
||||
- keycloak
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
platform: linux/x86_64
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:4.6.1
|
||||
@@ -137,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
|
||||
@@ -158,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
|
||||
@@ -168,10 +245,17 @@ 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
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"oauth2DeviceCodeLifespan": 600,
|
||||
"oauth2DevicePollingInterval": 5,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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"
|
||||
@@ -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)
|
||||
@@ -24,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices):
|
||||
FAILURE = "failure", _("Failure")
|
||||
PENDING = "pending", _("Pending")
|
||||
SUCCESS = "success", _("Success")
|
||||
|
||||
|
||||
class WebhookProtocolChoices(models.TextChoices):
|
||||
"""Defines the possible protocols of webhook."""
|
||||
|
||||
SCIM = "scim"
|
||||
MATRIX = "matrix"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2 on 2025-06-12 09:58
|
||||
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def gen_team_uuid(apps, schema_editor):
|
||||
Team = apps.get_model("core", "Team")
|
||||
for row in Team.objects.all():
|
||||
row.external_id = uuid.uuid4()
|
||||
# Don't need to batch update here, as the table is likely small or empty
|
||||
row.save(update_fields=["external_id"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_alter_accountservice_api_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='external_id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=False, verbose_name='external_id'),
|
||||
),
|
||||
migrations.RunPython(gen_team_uuid, reverse_code=migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='external_id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=True, verbose_name='external_id'),
|
||||
),
|
||||
#
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='users',
|
||||
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', through_fields=('team', 'user'), to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2025-06-17 14:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_team_external_id_alter_team_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='teamwebhook',
|
||||
name='protocol',
|
||||
field=models.CharField(choices=[('scim', 'Scim'), ('matrix', 'Matrix')], default='scim'),
|
||||
),
|
||||
]
|
||||
+31
-16
@@ -21,7 +21,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext, override
|
||||
@@ -31,9 +31,9 @@ import jsonschema
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from core.enums import WebhookStatusChoices
|
||||
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
|
||||
from core.plugins.registry import registry as plugin_hooks_registry
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.utils.webhooks import webhooks_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -742,6 +742,11 @@ class Team(MP_Node, BaseModel):
|
||||
can see it.
|
||||
When a team is created from a Service Provider this one is automatically set in the
|
||||
Team `service_providers`.
|
||||
|
||||
The team `external_id` is used to synchronize the team with external systems, this
|
||||
is the equivalent of the User `sub` field but for teams (note: `sub` is highly related
|
||||
to the OIDC standard, while `external_id` is not). The `external_id` is NOT the same as
|
||||
the `externalId` field in the SCIM standard when importing teams from SCIM.
|
||||
"""
|
||||
|
||||
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
|
||||
@@ -752,6 +757,14 @@ class Team(MP_Node, BaseModel):
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
external_id = models.UUIDField(
|
||||
verbose_name=_("external_id"),
|
||||
help_text=_("Team external UUID for synchronization with external systems"),
|
||||
unique=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
through="TeamAccess",
|
||||
@@ -851,16 +864,12 @@ class TeamAccess(BaseModel):
|
||||
Override save function to fire webhooks on any addition or update
|
||||
to a team access.
|
||||
"""
|
||||
|
||||
if self._state.adding:
|
||||
if self._state.adding and self.team.webhooks.exists():
|
||||
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
with transaction.atomic():
|
||||
instance = super().save(*args, **kwargs)
|
||||
scim_synchronizer.add_user_to_group(self.team, self.user)
|
||||
else:
|
||||
instance = super().save(*args, **kwargs)
|
||||
# try to synchronize all webhooks
|
||||
webhooks_synchronizer.add_user_to_group(self.team, self.user)
|
||||
|
||||
return instance
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
@@ -868,11 +877,12 @@ class TeamAccess(BaseModel):
|
||||
Don't allow deleting a team access until it is successfully synchronized with all
|
||||
its webhooks.
|
||||
"""
|
||||
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
with transaction.atomic():
|
||||
arguments = self.team, self.user
|
||||
super().delete(*args, **kwargs)
|
||||
scim_synchronizer.remove_user_from_group(*arguments)
|
||||
if webhooks := self.team.webhooks.all():
|
||||
webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
# try to synchronize all webhooks
|
||||
webhooks_synchronizer.remove_user_from_group(self.team, self.user)
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
@@ -930,6 +940,11 @@ class TeamWebhook(BaseModel):
|
||||
team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE)
|
||||
url = models.URLField(_("url"))
|
||||
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
|
||||
protocol = models.CharField(
|
||||
max_length=None,
|
||||
default=WebhookProtocolChoices.SCIM,
|
||||
choices=WebhookProtocolChoices.choices,
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
default=WebhookStatusChoices.PENDING,
|
||||
|
||||
+1
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -23,7 +23,6 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
|
||||
Convert valid domain invitations to domain accesses for a given user.
|
||||
Expired invitations are ignored.
|
||||
"""
|
||||
logger.info("Convert domain invitations for user %s", instance)
|
||||
if created:
|
||||
valid_domain_invitations = MailDomainInvitation.objects.filter(
|
||||
email=instance.email,
|
||||
@@ -36,6 +35,11 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
|
||||
if not valid_domain_invitations.exists():
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Converting %s domain invitations for new user %s",
|
||||
len(valid_domain_invitations),
|
||||
instance,
|
||||
)
|
||||
MailDomainAccess.objects.bulk_create(
|
||||
[
|
||||
MailDomainAccess(
|
||||
@@ -46,4 +50,3 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
|
||||
)
|
||||
|
||||
valid_domain_invitations.delete()
|
||||
logger.info("Invitations converted to domain accesses for user %s", instance)
|
||||
|
||||
+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."
|
||||
]
|
||||
|
||||
@@ -38,11 +38,15 @@ def test_models_domain_invitations__is_expired():
|
||||
assert expired_invitation.is_expired is True
|
||||
|
||||
|
||||
def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_joining():
|
||||
def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_joining(
|
||||
caplog,
|
||||
):
|
||||
"""
|
||||
Upon creating a new user, domain invitations linked to that email
|
||||
should be converted to accesses and then deleted.
|
||||
"""
|
||||
caplog.set_level("INFO")
|
||||
|
||||
# Two invitations to the same mail but to different domains
|
||||
email = "future_admin@example.com"
|
||||
invitation_to_domain1 = factories.MailDomainInvitationFactory(
|
||||
@@ -81,3 +85,8 @@ def test_models_domain_invitation__should_convert_invitations_to_accesses_upon_j
|
||||
assert models.MailDomainInvitation.objects.filter(
|
||||
domain=invitation_to_domain2.domain, email=other_invitation.email
|
||||
).exists() # the other invitation remains
|
||||
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
assert (
|
||||
f"Converting 2 domain invitations for new user {str(new_user)}" in log_messages
|
||||
)
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -601,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(
|
||||
|
||||
@@ -34,6 +34,7 @@ dependencies = [
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-extensions==4.1",
|
||||
"django-lasuite==0.0.9",
|
||||
"django-oauth-toolkit==3.0.1",
|
||||
"django-parler==2.3",
|
||||
@@ -70,7 +71,6 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2025.6.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.3.0",
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
server {
|
||||
listen 3000;
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
server_tokens off;
|
||||
|
||||
@@ -243,6 +243,10 @@ input:-webkit-autofill:focus {
|
||||
border-bottom: 1px var(--c--theme--colors--greyscale-100) solid;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tbody tr:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tbody {
|
||||
background-color: var(--c--components--datagrid--body--background-color);
|
||||
color: var(--c--theme--colors--greyscale-900);
|
||||
@@ -259,7 +263,7 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.c__datagrid__table__container > table th:first-child,
|
||||
.c__datagrid__table__container > table td:first-child {
|
||||
padding-left: 0;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tr:last-child {
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ describe('ModalCreateMailbox', () => {
|
||||
});
|
||||
|
||||
it('disables the submit button while the form is submitting', async () => {
|
||||
fetchMock.postOnce(apiUrl, new Promise(() => {})); // Simulate pending state
|
||||
fetchMock.postOnce(apiUrl, new Promise(() => {}));
|
||||
|
||||
renderModalCreateMailbox();
|
||||
|
||||
|
||||
+1
@@ -97,6 +97,7 @@ export function MailBoxesListView({
|
||||
{
|
||||
field: 'email',
|
||||
headerName: `${t('Address')} • ${filteredMailboxes.length}`,
|
||||
renderCell: ({ row }) => <Text>{row.email}</Text>,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
|
||||
+56
-50
@@ -10,13 +10,11 @@ import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { Box, Text } from '@/components';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { CustomModal } from '@/components/modal/CustomModal';
|
||||
import { ChooseRole } from '@/features/teams/member-management';
|
||||
import { Role, Team } from '@/features/teams/team-management';
|
||||
|
||||
import { useCreateInvitation, useCreateTeamAccess } from '../api';
|
||||
import IconAddMember from '../assets/add-member.svg';
|
||||
import {
|
||||
OptionInvitation,
|
||||
OptionNewMember,
|
||||
@@ -49,7 +47,6 @@ export const ModalAddMembers = ({
|
||||
onClose,
|
||||
team,
|
||||
}: ModalAddMembersProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { t } = useTranslation();
|
||||
const [selectedMembers, setSelectedMembers] = useState<OptionsSelect>([]);
|
||||
const [selectedRole, setSelectedRole] = useState<Role>(Role.MEMBER);
|
||||
@@ -135,10 +132,40 @@ export const ModalAddMembers = ({
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
leftActions={
|
||||
const step = 0;
|
||||
const steps = [
|
||||
{
|
||||
title: t('Add a member'),
|
||||
content: (
|
||||
<>
|
||||
<GlobalStyle />
|
||||
<Box
|
||||
$padding={{ horizontal: 'md', bottom: 'md' }}
|
||||
$margin={{ bottom: 'xl', top: 'large' }}
|
||||
>
|
||||
<SearchMembers
|
||||
team={team}
|
||||
setSelectedMembers={setSelectedMembers}
|
||||
selectedMembers={selectedMembers}
|
||||
disabled={isPending}
|
||||
/>
|
||||
{selectedMembers.length > 0 && (
|
||||
<Box $margin={{ top: 'small' }}>
|
||||
<Text as="h4" $textAlign="left" $margin={{ bottom: 'tiny' }}>
|
||||
{t('Choose a role')}
|
||||
</Text>
|
||||
<ChooseRole
|
||||
currentRole={currentRole}
|
||||
disabled={isPending}
|
||||
defaultRole={Role.MEMBER}
|
||||
setRole={setSelectedRole}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
leftAction: (
|
||||
<Button
|
||||
color="secondary"
|
||||
fullWidth
|
||||
@@ -147,56 +174,35 @@ export const ModalAddMembers = ({
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
rightActions={
|
||||
),
|
||||
rightAction: (
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
disabled={!selectedMembers.length || isPending}
|
||||
onClick={() => void handleValidate()}
|
||||
>
|
||||
{t('Add to group')}
|
||||
{t('Add to team')}
|
||||
</Button>
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen
|
||||
leftActions={steps[step].leftAction}
|
||||
rightActions={steps[step].rightAction}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconAddMember
|
||||
width={48}
|
||||
color={colorsTokens()['primary-text']}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Text $size="h3" $margin="none">
|
||||
{t('Add a member')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
title={steps[step].title}
|
||||
step={step}
|
||||
totalSteps={steps.length}
|
||||
closeOnEsc
|
||||
>
|
||||
<GlobalStyle />
|
||||
<Box $margin={{ bottom: 'xl', top: 'large' }}>
|
||||
<SearchMembers
|
||||
team={team}
|
||||
setSelectedMembers={setSelectedMembers}
|
||||
selectedMembers={selectedMembers}
|
||||
disabled={isPending}
|
||||
/>
|
||||
{selectedMembers.length > 0 && (
|
||||
<Box $margin={{ top: 'small' }}>
|
||||
<Text as="h4" $textAlign="left" $margin={{ bottom: 'tiny' }}>
|
||||
{t('Choose a role')}
|
||||
</Text>
|
||||
<ChooseRole
|
||||
currentRole={currentRole}
|
||||
disabled={isPending}
|
||||
defaultRole={Role.MEMBER}
|
||||
setRole={setSelectedRole}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
{steps[step].content}
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
|
||||
+58
-46
@@ -95,7 +95,8 @@ describe('MemberGrid', () => {
|
||||
expect(screen.getByText('user3@test.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Administration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Member')).toBeInTheDocument();
|
||||
const memberCells = screen.getAllByText('Member', { selector: 'td' });
|
||||
expect(memberCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('checks the pagination', async () => {
|
||||
@@ -221,9 +222,8 @@ describe('MemberGrid', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Names', 'user__name'],
|
||||
['Emails', 'user__email'],
|
||||
['Roles', 'role'],
|
||||
['Member', 'user__email'],
|
||||
['Role', 'role'],
|
||||
])('checks the sorting of %s', async (header_name, ordering) => {
|
||||
const mockedData = [
|
||||
{
|
||||
@@ -258,9 +258,25 @@ describe('MemberGrid', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const sortedMockedData = [...mockedData].sort((a, b) =>
|
||||
a.id > b.id ? 1 : -1,
|
||||
);
|
||||
let sortedMockedData;
|
||||
if (ordering === 'user__email') {
|
||||
sortedMockedData = [...mockedData].sort((a, b) =>
|
||||
a.user.email.localeCompare(b.user.email),
|
||||
);
|
||||
} else if (ordering === 'role') {
|
||||
const localized = {
|
||||
ADMIN: 'Administrator',
|
||||
MEMBER: 'Member',
|
||||
OWNER: 'Owner',
|
||||
};
|
||||
const getLocalized = (role: any) =>
|
||||
localized[role as keyof typeof localized] || '';
|
||||
sortedMockedData = [...mockedData].sort((a, b) =>
|
||||
getLocalized(a.role).localeCompare(getLocalized(b.role)),
|
||||
);
|
||||
} else {
|
||||
sortedMockedData = [...mockedData];
|
||||
}
|
||||
const reversedMockedData = [...sortedMockedData].reverse();
|
||||
|
||||
fetchMock.get(`end:/teams/123456/accesses/?page=1`, {
|
||||
@@ -291,47 +307,40 @@ describe('MemberGrid', () => {
|
||||
});
|
||||
|
||||
let rows = screen.getAllByRole('row');
|
||||
expect(rows[1]).toHaveTextContent('albert');
|
||||
expect(rows[2]).toHaveTextContent('philipp');
|
||||
expect(rows[3]).toHaveTextContent('fany');
|
||||
|
||||
expect(screen.queryByLabelText('arrow_drop_down')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText(header_name));
|
||||
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
`/teams/123456/accesses/?page=1&ordering=${ordering}`,
|
||||
expect(rows[1].textContent?.trim().toLowerCase().startsWith('albert')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
rows[2].textContent?.trim().toLowerCase().startsWith('philipp'),
|
||||
).toBe(true);
|
||||
expect(rows[3].textContent?.trim().toLowerCase().startsWith('fany')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
const header = headers.find((h) =>
|
||||
h.textContent?.trim().toLowerCase().startsWith(header_name.toLowerCase()),
|
||||
);
|
||||
expect(header).toBeDefined();
|
||||
await userEvent.click(header!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
let expectedOrder: string[] = [];
|
||||
if (header_name === 'Member') {
|
||||
rows = screen.getAllByRole('row');
|
||||
expectedOrder = ['albert', 'philipp', 'fany'];
|
||||
} else if (header_name === 'Role') {
|
||||
expectedOrder = ['albert', 'philipp', 'fany'];
|
||||
}
|
||||
rows = screen.getAllByRole('row');
|
||||
expect(rows[1]).toHaveTextContent('albert');
|
||||
expect(rows[2]).toHaveTextContent('fany');
|
||||
expect(rows[3]).toHaveTextContent('philipp');
|
||||
expect(rows[1].textContent?.toLowerCase()).toContain(expectedOrder[0]);
|
||||
expect(rows[2].textContent?.toLowerCase()).toContain(expectedOrder[1]);
|
||||
expect(rows[3].textContent?.toLowerCase()).toContain(expectedOrder[2]);
|
||||
|
||||
expect(await screen.findByText('arrow_drop_up')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText(header_name));
|
||||
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
`/teams/123456/accesses/?page=1&ordering=-${ordering}`,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
rows = screen.getAllByRole('row');
|
||||
expect(rows[1]).toHaveTextContent('philipp');
|
||||
expect(rows[2]).toHaveTextContent('fany');
|
||||
expect(rows[3]).toHaveTextContent('albert');
|
||||
|
||||
expect(await screen.findByText('arrow_drop_down')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText(header_name));
|
||||
await userEvent.click(header!);
|
||||
|
||||
expect(fetchMock.lastUrl()).toContain('/teams/123456/accesses/?page=1');
|
||||
|
||||
@@ -340,12 +349,15 @@ describe('MemberGrid', () => {
|
||||
});
|
||||
|
||||
rows = screen.getAllByRole('row');
|
||||
expect(rows[1]).toHaveTextContent('albert');
|
||||
expect(rows[2]).toHaveTextContent('philipp');
|
||||
expect(rows[3]).toHaveTextContent('fany');
|
||||
|
||||
expect(screen.queryByLabelText('arrow_drop_down')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
|
||||
expect(rows[1].textContent?.trim().toLowerCase().startsWith('albert')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
rows[2].textContent?.trim().toLowerCase().startsWith('philipp'),
|
||||
).toBe(true);
|
||||
expect(rows[3].textContent?.trim().toLowerCase().startsWith('fany')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('filters members based on the query', async () => {
|
||||
|
||||
+20
-68
@@ -82,17 +82,13 @@ describe('ModalRole', () => {
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Administration',
|
||||
}),
|
||||
).toBeChecked();
|
||||
const select = screen.getByRole('combobox', { name: /role/i });
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
);
|
||||
await userEvent.click(select);
|
||||
const memberOption = await screen.findByRole('option', { name: 'Member' });
|
||||
await userEvent.click(memberOption);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
@@ -111,7 +107,6 @@ describe('ModalRole', () => {
|
||||
});
|
||||
|
||||
expect(fetchMock.lastUrl()).toContain(`/teams/123/accesses/789/`);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -133,11 +128,10 @@ describe('ModalRole', () => {
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
);
|
||||
const select = screen.getByRole('combobox', { name: /role/i });
|
||||
await userEvent.click(select);
|
||||
const memberOption = await screen.findByRole('option', { name: 'Member' });
|
||||
await userEvent.click(memberOption);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
@@ -179,24 +173,8 @@ describe('ModalRole', () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Administration',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Owner',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
const select = screen.getByRole('combobox', { name: /role/i });
|
||||
expect(select).toHaveAttribute('disabled');
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Validate',
|
||||
@@ -232,23 +210,8 @@ describe('ModalRole', () => {
|
||||
screen.getByText('You cannot update the role of other owner.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Administration',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Owner',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
const select = screen.getByRole('combobox', { name: /role/i });
|
||||
expect(select).toHaveAttribute('disabled');
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
@@ -257,7 +220,7 @@ describe('ModalRole', () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('checks the render when current user is admin', () => {
|
||||
it('checks the render when current user is admin', async () => {
|
||||
render(
|
||||
<ModalRole
|
||||
access={access}
|
||||
@@ -268,22 +231,11 @@ describe('ModalRole', () => {
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
const select = screen.getByRole('combobox', { name: /role/i });
|
||||
expect(select).toBeEnabled();
|
||||
await userEvent.click(select);
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Administration',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Owner',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
const adminOption = screen.getByRole('option', { name: 'Administrator' });
|
||||
expect(adminOption).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
+18
-27
@@ -1,4 +1,4 @@
|
||||
import { Radio, RadioGroup } from '@openfun/cunningham-react';
|
||||
import { Select } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Role } from '@/features/teams/team-management';
|
||||
@@ -18,32 +18,23 @@ export const ChooseRole = ({
|
||||
}: ChooseRoleProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = [
|
||||
{ label: t('Member'), value: Role.MEMBER, disabled: false },
|
||||
{ label: t('Administrator'), value: Role.ADMIN, disabled: false },
|
||||
{
|
||||
label: t('Owner'),
|
||||
value: Role.OWNER,
|
||||
disabled: disabled || currentRole !== Role.OWNER,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<RadioGroup>
|
||||
<Radio
|
||||
label={t('Member')}
|
||||
value={Role.MEMBER}
|
||||
name="role"
|
||||
onChange={(evt) => setRole(evt.target.value as Role)}
|
||||
defaultChecked={defaultRole === Role.MEMBER}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Radio
|
||||
label={t('Administration')}
|
||||
value={Role.ADMIN}
|
||||
name="role"
|
||||
onChange={(evt) => setRole(evt.target.value as Role)}
|
||||
defaultChecked={defaultRole === Role.ADMIN}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Radio
|
||||
label={t('Owner')}
|
||||
value={Role.OWNER}
|
||||
name="role"
|
||||
onChange={(evt) => setRole(evt.target.value as Role)}
|
||||
defaultChecked={defaultRole === Role.OWNER}
|
||||
disabled={disabled || currentRole !== Role.OWNER}
|
||||
/>
|
||||
</RadioGroup>
|
||||
<Select
|
||||
label={t('Role')}
|
||||
value={defaultRole}
|
||||
onChange={(evt) => setRole(evt.target.value as Role)}
|
||||
disabled={disabled}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ export const MemberAction = ({
|
||||
return (
|
||||
<>
|
||||
<DropButton
|
||||
aria-label={t('Member options')}
|
||||
button={<IconOptions aria-label={t('Open the member options modal')} />}
|
||||
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
|
||||
isOpen={isDropOpen}
|
||||
|
||||
+64
-75
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDebounce } from '@/api';
|
||||
import IconUser from '@/assets/icons/icon-user.svg';
|
||||
import { Box, Card, Text, TextErrors } from '@/components';
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ModalAddMembers } from '@/features/teams/member-add';
|
||||
import { Role, Team } from '@/features/teams/team-management';
|
||||
@@ -110,104 +110,92 @@ export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
|
||||
}, [data?.count, pageSize, setPagesCount]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box $margin={{ horizontal: 'big', bottom: 'small' }}>
|
||||
<Text
|
||||
$theme="primary"
|
||||
$weight="bold"
|
||||
$size="h3"
|
||||
$margin={{ bottom: 'big' }}
|
||||
>
|
||||
<div aria-label="Team panel" className="container">
|
||||
<Box
|
||||
aria-label={t('List members card')}
|
||||
$padding={{ all: 'md' }}
|
||||
$background="white"
|
||||
$justify="space-between"
|
||||
$gap="8px"
|
||||
$radius="4px"
|
||||
$direction="column"
|
||||
$css={`
|
||||
border: 1px solid ${colorsTokens()['greyscale-200']};
|
||||
`}
|
||||
>
|
||||
<h3 style={{ fontWeight: 700, fontSize: '18px', marginBottom: 'base' }}>
|
||||
{t('Group members')}
|
||||
</Text>
|
||||
</h3>
|
||||
<Box
|
||||
$display="flex"
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$align="center"
|
||||
$gap="1rem"
|
||||
$css={`
|
||||
& > * {
|
||||
flex: 0.23 0 auto;
|
||||
}
|
||||
`}
|
||||
$gap="1em"
|
||||
$css="width: 100%; margin-bottom: 20px;"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: '1em',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 'calc(100% - 175px)' }}>
|
||||
<Input
|
||||
label={t('Filter member list')}
|
||||
icon={<IconMagnifyingGlass />}
|
||||
onChange={(event) => setQueryValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Box $css="width: calc(100% - 210px);">
|
||||
<Input
|
||||
label={t('Filter member list')}
|
||||
icon={<IconMagnifyingGlass />}
|
||||
value={queryValue}
|
||||
onChange={(event) => setQueryValue(event.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
className="hidden md:flex"
|
||||
$css={`
|
||||
background: ${colorsTokens()['greyscale-200']};
|
||||
height: 32px;
|
||||
width: 1px;
|
||||
`}
|
||||
/>
|
||||
<Box className="block md:hidden" $css="margin-bottom: 10px;" />
|
||||
<Box>
|
||||
{currentRole !== Role.MEMBER && (
|
||||
<Button
|
||||
aria-label={t('Add members to the team')}
|
||||
style={{
|
||||
minWidth: 'auto',
|
||||
maxWidth: 'fit-content',
|
||||
}}
|
||||
style={{ minWidth: 'auto', maxWidth: 'fit-content' }}
|
||||
onClick={() => setIsModalMemberOpen(true)}
|
||||
>
|
||||
{t('Add a member')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Card
|
||||
$padding={{ bottom: 'small' }}
|
||||
$margin={{ all: 'big', top: 'none' }}
|
||||
$overflow="auto"
|
||||
$css={`
|
||||
& .c__pagination__goto {
|
||||
display: none;
|
||||
}
|
||||
& table th:first-child,
|
||||
& table td:first-child {
|
||||
padding-right: 0;
|
||||
width: 3.5rem;
|
||||
}
|
||||
& table td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
`}
|
||||
aria-label={t('List members card')}
|
||||
>
|
||||
{error && <TextErrors causes={error.cause} />}
|
||||
|
||||
{error && <TextErrors causes={error.cause} />}
|
||||
<DataGrid
|
||||
columns={[
|
||||
{
|
||||
id: 'icon-user',
|
||||
renderCell() {
|
||||
return (
|
||||
<Box $direction="row" $align="center">
|
||||
headerName: t('Member'),
|
||||
id: 'user-info',
|
||||
renderCell: ({ row }) => (
|
||||
<>
|
||||
<Box $direction="row" $align="center" $gap="1.2em">
|
||||
<IconUser
|
||||
aria-label={t('Member icon')}
|
||||
width={20}
|
||||
height={20}
|
||||
color={colorsTokens()['primary-600']}
|
||||
/>
|
||||
<div>
|
||||
<Text
|
||||
$weight="500"
|
||||
$css="text-transform: capitalize;"
|
||||
color={colorsTokens()['greyscale-1000']}
|
||||
>
|
||||
{row.user.name}
|
||||
</Text>
|
||||
<Text
|
||||
$weight="400"
|
||||
color={colorsTokens()['greyscale-600']}
|
||||
>
|
||||
{row.user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: t('Names'),
|
||||
field: 'user.name',
|
||||
},
|
||||
{
|
||||
field: 'user.email',
|
||||
headerName: t('Emails'),
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'localizedRole',
|
||||
@@ -226,13 +214,14 @@ export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
|
||||
},
|
||||
},
|
||||
]}
|
||||
aria-label={t('List members card')}
|
||||
rows={accesses}
|
||||
isLoading={isLoading}
|
||||
pagination={pagination}
|
||||
onSortModelChange={setSortModel}
|
||||
sortModel={sortModel}
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
{isModalMemberOpen && (
|
||||
<ModalAddMembers
|
||||
currentRole={currentRole}
|
||||
@@ -240,6 +229,6 @@ export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+76
-64
@@ -9,7 +9,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import IconUser from '@/assets/icons/icon-user.svg';
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { CustomModal } from '@/components/modal/CustomModal';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Role, Team } from '@/features/teams/team-management';
|
||||
|
||||
@@ -56,18 +56,66 @@ export const ModalDelete = ({ access, onClose, team }: ModalDeleteProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
leftActions={
|
||||
const step = 0;
|
||||
const steps = [
|
||||
{
|
||||
title: t('Remove this member from the group'),
|
||||
content: (
|
||||
<Box
|
||||
$padding={{ horizontal: 'md', bottom: 'md' }}
|
||||
aria-label={t('Radio buttons to update the roles')}
|
||||
>
|
||||
<Text>
|
||||
{t(
|
||||
'Are you sure you want to remove this member from the {{team}} group?',
|
||||
{ team: team.name },
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{isErrorUpdate && (
|
||||
<TextErrors
|
||||
$margin={{ bottom: 'small' }}
|
||||
causes={errorUpdate.cause}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLastOwner || isOtherOwner) && (
|
||||
<Text
|
||||
$theme="warning"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="1rem"
|
||||
$margin="md"
|
||||
$justify="center"
|
||||
>
|
||||
<span className="material-icons">warning</span>
|
||||
{isLastOwner &&
|
||||
t(
|
||||
'You are the last owner, you cannot be removed from your team.',
|
||||
)}
|
||||
{isOtherOwner && t('You cannot remove other owner.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
$padding="big"
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$background={colorsTokens()['primary-150']}
|
||||
$theme="primary"
|
||||
>
|
||||
<IconUser width={20} height={20} aria-hidden="true" />
|
||||
<Text>{access.user.name}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
leftAction: (
|
||||
<Button color="secondary" fullWidth onClick={() => onClose()}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
rightActions={
|
||||
),
|
||||
rightAction: (
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
@@ -81,61 +129,25 @@ export const ModalDelete = ({ access, onClose, team }: ModalDeleteProps) => {
|
||||
>
|
||||
{t('Remove from the group')}
|
||||
</Button>
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
leftActions={steps[step].leftAction}
|
||||
rightActions={steps[step].rightAction}
|
||||
onClose={onClose}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<Text $size="h3" $margin="none">
|
||||
{t('Remove this member from the group')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
title={steps[step].title}
|
||||
step={step}
|
||||
totalSteps={steps.length}
|
||||
closeOnEsc
|
||||
>
|
||||
<Box aria-label={t('Radio buttons to update the roles')}>
|
||||
<Text>
|
||||
{t(
|
||||
'Are you sure you want to remove this member from the {{team}} group?',
|
||||
{ team: team.name },
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{isErrorUpdate && (
|
||||
<TextErrors
|
||||
$margin={{ bottom: 'small' }}
|
||||
causes={errorUpdate.cause}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLastOwner || isOtherOwner) && (
|
||||
<Text
|
||||
$theme="warning"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="0.5rem"
|
||||
$margin="tiny"
|
||||
$justify="center"
|
||||
>
|
||||
<span className="material-icons">warning</span>
|
||||
{isLastOwner &&
|
||||
t(
|
||||
'You are the last owner, you cannot be removed from your team.',
|
||||
)}
|
||||
{isOtherOwner && t('You cannot remove other owner.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
$padding="big"
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$background={colorsTokens()['primary-150']}
|
||||
$theme="primary"
|
||||
>
|
||||
<IconUser width={20} height={20} aria-hidden="true" />
|
||||
<Text>{access.user.name}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Modal>
|
||||
{steps[step].content}
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
|
||||
+71
-46
@@ -8,7 +8,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { CustomModal } from '@/components/modal/CustomModal';
|
||||
import { Role } from '@/features/teams/team-management';
|
||||
|
||||
import { useUpdateTeamAccess } from '../api/useUpdateTeamAccess';
|
||||
@@ -50,10 +50,56 @@ export const ModalRole = ({
|
||||
|
||||
const isNotAllowed = isOtherOwner || isLastOwner;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
leftActions={
|
||||
const step = 0;
|
||||
const steps = [
|
||||
{
|
||||
title: t('Update the role'),
|
||||
content: (
|
||||
<Box
|
||||
$padding={{ horizontal: 'md', bottom: 'md' }}
|
||||
aria-label={t('Radio buttons to update the roles')}
|
||||
>
|
||||
{isErrorUpdate && (
|
||||
<TextErrors
|
||||
$margin={{ bottom: 'small' }}
|
||||
causes={errorUpdate.cause}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text $margin={{ bottom: 'md' }}>
|
||||
{' '}
|
||||
{t('Update the role of {{memberName}}', {
|
||||
memberName: access.user.name,
|
||||
})}{' '}
|
||||
</Text>
|
||||
|
||||
{(isLastOwner || isOtherOwner) && (
|
||||
<Text
|
||||
$theme="warning"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="1rem"
|
||||
$margin={{ bottom: 'md' }}
|
||||
$justify="center"
|
||||
>
|
||||
<span className="material-icons">warning</span>
|
||||
{isLastOwner &&
|
||||
t(
|
||||
'You are the sole owner of this group. Make another member the group owner, before you can change your own role.',
|
||||
)}
|
||||
{isOtherOwner && t('You cannot update the role of other owner.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ChooseRole
|
||||
defaultRole={access.role}
|
||||
currentRole={currentRole}
|
||||
disabled={isNotAllowed}
|
||||
setRole={setLocalRole}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
leftAction: (
|
||||
<Button
|
||||
color="secondary"
|
||||
fullWidth
|
||||
@@ -62,11 +108,8 @@ export const ModalRole = ({
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => onClose()}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
rightActions={
|
||||
),
|
||||
rightAction: (
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
@@ -81,43 +124,25 @@ export const ModalRole = ({
|
||||
>
|
||||
{t('Validate')}
|
||||
</Button>
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen
|
||||
leftActions={steps[step].leftAction}
|
||||
rightActions={steps[step].rightAction}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={t('Update the role')}
|
||||
title={steps[step].title}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
closeOnEsc
|
||||
step={step}
|
||||
totalSteps={steps.length}
|
||||
>
|
||||
<Box aria-label={t('Radio buttons to update the roles')}>
|
||||
{isErrorUpdate && (
|
||||
<TextErrors
|
||||
$margin={{ bottom: 'small' }}
|
||||
causes={errorUpdate.cause}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLastOwner || isOtherOwner) && (
|
||||
<Text
|
||||
$theme="warning"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="0.5rem"
|
||||
$margin={{ bottom: 'tiny' }}
|
||||
$justify="center"
|
||||
>
|
||||
<span className="material-icons">warning</span>
|
||||
{isLastOwner &&
|
||||
t(
|
||||
'You are the sole owner of this group. Make another member the group owner, before you can change your own role.',
|
||||
)}
|
||||
{isOtherOwner && t('You cannot update the role of other owner.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ChooseRole
|
||||
defaultRole={access.role}
|
||||
currentRole={currentRole}
|
||||
disabled={isNotAllowed}
|
||||
setRole={setLocalRole}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
{steps[step].content}
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import IconGroup from '@/assets/icons/icon-group2.svg';
|
||||
import { Box, Card, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { useCreateTeam } from '../api';
|
||||
|
||||
import { InputTeamName } from './InputTeamName';
|
||||
|
||||
export const CardCreateTeam = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
mutate: createTeam,
|
||||
isError,
|
||||
isPending,
|
||||
error,
|
||||
} = useCreateTeam({
|
||||
onSuccess: (team) => {
|
||||
router.push(`/teams/${team.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Card
|
||||
$padding="big"
|
||||
$height="70%"
|
||||
$justify="space-between"
|
||||
$width="100%"
|
||||
$maxWidth="24rem"
|
||||
$minWidth="22rem"
|
||||
aria-label={t('Create new team card')}
|
||||
>
|
||||
<Box $gap="1rem">
|
||||
<Box $align="center">
|
||||
<IconGroup
|
||||
aria-hidden="true"
|
||||
width={44}
|
||||
color={colorsTokens()['primary-text']}
|
||||
/>
|
||||
<Text as="h3" $textAlign="center">
|
||||
{t('Create a new group')}
|
||||
</Text>
|
||||
</Box>
|
||||
<InputTeamName
|
||||
label={t('Team name')}
|
||||
{...{ error, isError, isPending, setTeamName }}
|
||||
/>
|
||||
</Box>
|
||||
<Box $justify="space-between" $direction="row" $align="center">
|
||||
<Button color="secondary" onClick={() => router.push('/')}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => createTeam(teamName)}
|
||||
disabled={!teamName || isPending}
|
||||
>
|
||||
{t('Create the team')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -60,6 +60,7 @@ export const InputTeamName = ({
|
||||
fullWidth
|
||||
type="text"
|
||||
label={label}
|
||||
aria-label={label}
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e) => {
|
||||
setTeamName(e.target.value);
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { Button, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { CustomModal } from '@/components/modal/CustomModal';
|
||||
import { useCreateTeam } from '@/features/teams/team-management/api';
|
||||
import { InputTeamName } from '@/features/teams/team-management/components/InputTeamName';
|
||||
|
||||
export const ModalCreateTeam = ({ closeModal }: { closeModal: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const step = 0;
|
||||
|
||||
const {
|
||||
mutate: createTeam,
|
||||
isError,
|
||||
isPending,
|
||||
error,
|
||||
} = useCreateTeam({
|
||||
onSuccess: (team) => {
|
||||
router.push(`/teams/${team.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const [teamName, setTeamName] = useState('');
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: t('Create a new team'),
|
||||
content: (
|
||||
<InputTeamName
|
||||
label={t('Team name')}
|
||||
{...{ error, isError, isPending, setTeamName }}
|
||||
/>
|
||||
),
|
||||
leftAction: (
|
||||
<Button color="secondary" onClick={closeModal}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
),
|
||||
rightAction: (
|
||||
<Button
|
||||
onClick={() => createTeam(teamName)}
|
||||
disabled={!teamName || isPending}
|
||||
>
|
||||
{t('Create the team')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen
|
||||
step={step}
|
||||
totalSteps={steps.length}
|
||||
leftActions={steps[step].leftAction}
|
||||
hideCloseButton
|
||||
closeOnClickOutside
|
||||
onClose={closeModal}
|
||||
closeOnEsc
|
||||
rightActions={steps[step].rightAction}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={steps[step].title}
|
||||
>
|
||||
<Box $padding="md">{steps[step].content}</Box>
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Button,
|
||||
Loader,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import IconGroup from '@/assets/icons/icon-group.svg';
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { CustomModal } from '@/components/modal/CustomModal';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { useRemoveTeam } from '../api/useRemoveTeam';
|
||||
import { Team } from '../types';
|
||||
|
||||
interface ModalDeleteTeamProps {
|
||||
onClose: () => void;
|
||||
team: Team;
|
||||
}
|
||||
|
||||
export const ModalDeleteTeam = ({ onClose, team }: ModalDeleteTeamProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { toast } = useToastProvider();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
mutate: removeTeam,
|
||||
isError,
|
||||
isPending,
|
||||
error,
|
||||
} = useRemoveTeam({
|
||||
onSuccess: () => {
|
||||
toast(t('The team has been removed.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
router.push('/');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen
|
||||
hideCloseButton
|
||||
closeOnClickOutside
|
||||
closeOnEsc
|
||||
onClose={onClose}
|
||||
leftActions={
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
rightActions={
|
||||
<Button
|
||||
aria-label={t('Confirm deletion')}
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
removeTeam({
|
||||
teamId: team.id,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Confirm deletion')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={t('Deleting the {{teamName}} team', { teamName: team.name })}
|
||||
>
|
||||
<Box $padding="md">
|
||||
<Box aria-label={t('Content modal to delete the team')}>
|
||||
<Text as="p" $margin={{ bottom: 'big' }}>
|
||||
{t('Are you sure you want to delete {{teamName}} team?', {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{isError && (
|
||||
<TextErrors $margin={{ bottom: 'small' }} causes={error.cause} />
|
||||
)}
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
$padding="small"
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$background={colorsTokens()['primary-150']}
|
||||
$theme="primary"
|
||||
$align="center"
|
||||
$radius="2px"
|
||||
>
|
||||
<IconGroup
|
||||
className="p-t"
|
||||
aria-hidden="true"
|
||||
color={colorsTokens()['primary-500']}
|
||||
width={58}
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
border: `1px solid ${colorsTokens()['primary-300']}`,
|
||||
}}
|
||||
/>
|
||||
<Text $theme="primary" $weight="bold" $size="l">
|
||||
{team.name}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{isPending && (
|
||||
<Box $align="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import IconGroup from '@/assets/icons/icon-group.svg';
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { useRemoveTeam } from '../api/useRemoveTeam';
|
||||
import IconRemove from '../assets/icon-trash.svg';
|
||||
import { Team } from '../types';
|
||||
|
||||
interface ModalRemoveTeamProps {
|
||||
onClose: () => void;
|
||||
team: Team;
|
||||
}
|
||||
|
||||
export const ModalRemoveTeam = ({ onClose, team }: ModalRemoveTeamProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { toast } = useToastProvider();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
mutate: removeTeam,
|
||||
isError,
|
||||
error,
|
||||
} = useRemoveTeam({
|
||||
onSuccess: () => {
|
||||
toast(t('The team has been removed.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
router.push('/');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
leftActions={
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => onClose()}
|
||||
rightActions={
|
||||
<Button
|
||||
aria-label={t('Confirm deletion')}
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
removeTeam({
|
||||
teamId: team.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Confirm deletion')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconRemove
|
||||
width={48}
|
||||
color={colorsTokens()['primary-text']}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Text $size="h3" $margin="none">
|
||||
{t('Deleting the {{teamName}} team', { teamName: team.name })}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
$margin={{ bottom: 'xl' }}
|
||||
aria-label={t('Content modal to delete the team')}
|
||||
>
|
||||
<Text as="p" $margin={{ bottom: 'big' }}>
|
||||
{t('Are you sure you want to delete {{teamName}} team?', {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{isError && (
|
||||
<TextErrors $margin={{ bottom: 'small' }} causes={error.cause} />
|
||||
)}
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
$padding="small"
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$background={colorsTokens()['primary-150']}
|
||||
$theme="primary"
|
||||
$align="center"
|
||||
$radius="2px"
|
||||
>
|
||||
<IconGroup
|
||||
className="p-t"
|
||||
aria-hidden="true"
|
||||
color={colorsTokens()['primary-500']}
|
||||
width={58}
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
border: `1px solid ${colorsTokens()['primary-300']}`,
|
||||
}}
|
||||
/>
|
||||
<Text $theme="primary" $weight="bold" $size="l">
|
||||
{team.name}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+42
-42
@@ -8,11 +8,9 @@ import { t } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { CustomModal } from '@/components/modal/CustomModal';
|
||||
|
||||
import { useUpdateTeam } from '../api';
|
||||
import IconEdit from '../assets/icon-edit.svg';
|
||||
import { Team } from '../types';
|
||||
|
||||
import { InputTeamName } from './InputTeamName';
|
||||
@@ -23,7 +21,6 @@ interface ModalUpdateTeamProps {
|
||||
}
|
||||
|
||||
export const ModalUpdateTeam = ({ onClose, team }: ModalUpdateTeamProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [teamName, setTeamName] = useState(team.name);
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
@@ -41,12 +38,26 @@ export const ModalUpdateTeam = ({ onClose, team }: ModalUpdateTeamProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
leftActions={
|
||||
const step = 0;
|
||||
const steps = [
|
||||
{
|
||||
title: t('Update team {{teamName}}', { teamName: team.name }),
|
||||
content: (
|
||||
<Box
|
||||
$padding={{ bottom: 'md' }}
|
||||
aria-label={t('Content modal to update the team')}
|
||||
>
|
||||
<Text as="p" $margin={{ bottom: 'big' }}>
|
||||
{t('Enter the new name of the selected team')}
|
||||
</Text>
|
||||
<InputTeamName
|
||||
label={t('New name...')}
|
||||
defaultValue={team.name}
|
||||
{...{ error, isError, isPending, setTeamName }}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
leftAction: (
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
@@ -55,9 +66,8 @@ export const ModalUpdateTeam = ({ onClose, team }: ModalUpdateTeamProps) => {
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => onClose()}
|
||||
rightActions={
|
||||
),
|
||||
rightAction: (
|
||||
<Button
|
||||
aria-label={t('Validate the modification')}
|
||||
color="primary"
|
||||
@@ -71,35 +81,25 @@ export const ModalUpdateTeam = ({ onClose, team }: ModalUpdateTeamProps) => {
|
||||
>
|
||||
{t('Validate the modification')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconEdit
|
||||
width={48}
|
||||
color={colorsTokens()['primary-text']}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Text $size="h3" $margin="none">
|
||||
{t('Update team {{teamName}}', { teamName: team.name })}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
$margin={{ bottom: 'xl' }}
|
||||
aria-label={t('Content modal to update the team')}
|
||||
>
|
||||
<Text as="p" $margin={{ bottom: 'big' }}>
|
||||
{t('Enter the new name of the selected team')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
<InputTeamName
|
||||
label={t('New name...')}
|
||||
defaultValue={team.name}
|
||||
{...{ error, isError, isPending, setTeamName }}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen
|
||||
step={step}
|
||||
totalSteps={steps.length}
|
||||
leftActions={steps[step].leftAction}
|
||||
rightActions={steps[step].rightAction}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={steps[step].title}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
closeOnEsc
|
||||
>
|
||||
<Box $padding={{ horizontal: 'md' }}>{steps[step].content}</Box>
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { Box, DropButton, IconOptions, Text } from '@/components';
|
||||
|
||||
import { Role, Team } from '../types';
|
||||
|
||||
import { ModalRemoveTeam } from './ModalRemoveTeam';
|
||||
import { ModalDeleteTeam } from './ModalDeleteTeam';
|
||||
import { ModalUpdateTeam } from './ModalUpdateTeam';
|
||||
|
||||
interface TeamActionsProps {
|
||||
@@ -71,7 +71,7 @@ export const TeamActions = ({ currentRole, team }: TeamActionsProps) => {
|
||||
/>
|
||||
)}
|
||||
{isModalRemoveOpen && (
|
||||
<ModalRemoveTeam
|
||||
<ModalDeleteTeam
|
||||
onClose={() => setIsModalRemoveOpen(false)}
|
||||
team={team}
|
||||
/>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import IconGroup from '@/assets/icons/icon-group2.svg';
|
||||
import { Box, Card, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Role, Team } from '../types';
|
||||
|
||||
import { ModalUpdateTeam } from './ModalUpdateTeam';
|
||||
import { TeamActions } from './TeamActions';
|
||||
|
||||
const format: DateTimeFormatOptions = {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
interface TeamInfoProps {
|
||||
team: Team;
|
||||
currentRole: Role;
|
||||
}
|
||||
|
||||
export const TeamInfo = ({ team, currentRole }: TeamInfoProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { i18n } = useTranslation();
|
||||
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
|
||||
|
||||
const created_at = DateTime.fromISO(team.created_at)
|
||||
.setLocale(i18n.language)
|
||||
.toLocaleString(format);
|
||||
|
||||
const updated_at = DateTime.fromISO(team.updated_at)
|
||||
.setLocale(i18n.language)
|
||||
.toLocaleString(format);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card $margin="big" $padding={{ bottom: 'none' }}>
|
||||
<Box $css="align-self: flex-end;" $margin="tiny" $position="absolute">
|
||||
<TeamActions currentRole={currentRole} team={team} />
|
||||
</Box>
|
||||
<Box $margin="big" $direction="row" $align="center" $gap="1.5rem">
|
||||
<IconGroup
|
||||
aria-hidden="true"
|
||||
width={44}
|
||||
color={colorsTokens()['primary-text']}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
alignSelf: 'start',
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Text
|
||||
as="h3"
|
||||
$weight="bold"
|
||||
$size="1.25rem"
|
||||
$margin={{ top: 'none' }}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
<Text $size="m">{t('Group details')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
$padding={{ all: 'small', left: '1.5rem' }}
|
||||
$gap="3rem"
|
||||
$direction="row"
|
||||
$justify="start"
|
||||
$css={`
|
||||
border-top: 1px solid ${colorsTokens()['greyscale-200']};
|
||||
`}
|
||||
>
|
||||
<Text $size="s" as="p">
|
||||
{t('{{count}} member', { count: team.accesses.length })}
|
||||
</Text>
|
||||
<Text $size="s" $display="inline" as="p">
|
||||
{t('Created at')}
|
||||
<Text $weight="bold" $display="inline">
|
||||
{created_at}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text $size="s" $display="inline" as="p">
|
||||
{t('Last update at')}
|
||||
<Text $weight="bold" $display="inline">
|
||||
{updated_at}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
{isModalUpdateOpen && (
|
||||
<ModalUpdateTeam
|
||||
onClose={() => setIsModalUpdateOpen(false)}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Panel } from '@/features/teams/teams-panel';
|
||||
import { MainLayout } from '@/layouts';
|
||||
|
||||
export function TeamLayout({ children }: PropsWithChildren) {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Box $height="inherit" $direction="row">
|
||||
<Panel />
|
||||
<Box
|
||||
$background={colorsTokens()['greyscale-050']}
|
||||
$width="100%"
|
||||
$height="inherit"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, IconOptions, Text } from '@/components';
|
||||
import { DropButton } from '@/components/DropButton';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Role, Team } from '../types';
|
||||
|
||||
import { ModalDeleteTeam } from './ModalDeleteTeam';
|
||||
import { ModalUpdateTeam } from './ModalUpdateTeam';
|
||||
|
||||
const format: DateTimeFormatOptions = {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
interface TeamViewProps {
|
||||
team: Team;
|
||||
currentRole: Role;
|
||||
}
|
||||
|
||||
export const TeamView = ({ team, currentRole }: TeamViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { i18n } = useTranslation();
|
||||
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
|
||||
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
|
||||
const [isDropOpen, setIsDropOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
if (
|
||||
!team ||
|
||||
typeof team !== 'object' ||
|
||||
!('created_at' in team) ||
|
||||
!('updated_at' in team) ||
|
||||
!('name' in team)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const created_at = DateTime.fromISO(String(team.created_at))
|
||||
.setLocale(i18n.language)
|
||||
.toLocaleString(format);
|
||||
|
||||
const updated_at = DateTime.fromISO(String(team.updated_at))
|
||||
.setLocale(i18n.language)
|
||||
.toLocaleString(format);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box aria-label="Team panel" className="container">
|
||||
<Box
|
||||
$padding={{ horizontal: 'md' }}
|
||||
$background="white"
|
||||
$justify="space-between"
|
||||
$gap="8px"
|
||||
$align="center"
|
||||
$radius="4px"
|
||||
$direction="row"
|
||||
$css={`
|
||||
border: 1px solid ${colorsTokens()['greyscale-200']};
|
||||
`}
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap="8px">
|
||||
<Button
|
||||
onClick={() => {
|
||||
void router.push('/teams/');
|
||||
}}
|
||||
icon={<span className="material-icons">arrow_back</span>}
|
||||
iconPosition="left"
|
||||
color="secondary"
|
||||
style={{
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{t('Teams')}
|
||||
</Button>
|
||||
<Box>
|
||||
<Text
|
||||
as="h3"
|
||||
$padding={{ left: '1.5rem' }}
|
||||
$size="h5"
|
||||
$weight="bold"
|
||||
$theme="greyscale"
|
||||
>
|
||||
{String(team.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box $align="center">
|
||||
{currentRole !== Role.MEMBER && (
|
||||
<DropButton
|
||||
button={<IconOptions aria-label={t('Open the team options')} />}
|
||||
onOpenChange={(open: boolean) => setIsDropOpen(open)}
|
||||
isOpen={isDropOpen}
|
||||
>
|
||||
<Box>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsModalUpdateOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
edit
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Text $theme="primary">{t('Update the team')}</Text>
|
||||
</Button>
|
||||
{currentRole === Role.OWNER && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsModalDeleteOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
delete
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Text $theme="primary">{t('Delete the team')}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</DropButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box $padding={{ all: '0.5em' }} $direction="row">
|
||||
<Text
|
||||
$size="s"
|
||||
$padding={{ left: '1.5rem' }}
|
||||
$display="inline"
|
||||
as="p"
|
||||
>
|
||||
{t('Created at')}
|
||||
<Text $weight="bold" $display="inline">
|
||||
{created_at}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
$size="s"
|
||||
$padding={{ left: '1.5rem' }}
|
||||
$display="inline"
|
||||
as="p"
|
||||
>
|
||||
{t('Last update at')}
|
||||
<Text $weight="bold" $display="inline">
|
||||
{updated_at}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isModalUpdateOpen && (
|
||||
<ModalUpdateTeam
|
||||
onClose={() => setIsModalUpdateOpen(false)}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isModalDeleteOpen && (
|
||||
<ModalDeleteTeam
|
||||
onClose={() => setIsModalDeleteOpen(false)}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+13
-34
@@ -5,7 +5,9 @@ import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { CardCreateTeam } from '../CardCreateTeam';
|
||||
import { ModalCreateTeam } from '../ModalCreateTeam';
|
||||
|
||||
const mockClose = jest.fn();
|
||||
|
||||
const mockPush = jest.fn();
|
||||
jest.mock('next/navigation', () => ({
|
||||
@@ -14,9 +16,9 @@ jest.mock('next/navigation', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CardCreateTeam', () => {
|
||||
const renderCardCreateTeam = () =>
|
||||
render(<CardCreateTeam />, { wrapper: AppWrapper });
|
||||
describe('ModalCreateTeam', () => {
|
||||
const renderModal = () =>
|
||||
render(<ModalCreateTeam closeModal={mockClose} />, { wrapper: AppWrapper });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -27,10 +29,8 @@ describe('CardCreateTeam', () => {
|
||||
});
|
||||
|
||||
it('renders all the elements', () => {
|
||||
renderCardCreateTeam();
|
||||
|
||||
expect(screen.getByLabelText('Create new team card')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create a new group')).toBeInTheDocument();
|
||||
renderModal();
|
||||
expect(screen.getByText('Create a new team')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Team name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create the team')).toBeInTheDocument();
|
||||
@@ -38,13 +38,10 @@ describe('CardCreateTeam', () => {
|
||||
|
||||
it('handles input for team name and enables submit button', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCardCreateTeam();
|
||||
|
||||
renderModal();
|
||||
const teamNameInput = screen.getByLabelText('Team name');
|
||||
const createButton = screen.getByText('Create the team');
|
||||
|
||||
expect(createButton).toBeDisabled();
|
||||
|
||||
await user.type(teamNameInput, 'New Team');
|
||||
expect(createButton).toBeEnabled();
|
||||
});
|
||||
@@ -54,22 +51,17 @@ describe('CardCreateTeam', () => {
|
||||
id: '270328ea-c2c0-4f74-a449-5cdc976dcdb6',
|
||||
name: 'New Team',
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCardCreateTeam();
|
||||
|
||||
renderModal();
|
||||
const teamNameInput = screen.getByLabelText('Team name');
|
||||
const createButton = screen.getByText('Create the team');
|
||||
|
||||
await user.type(teamNameInput, 'New Team');
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
'/teams/270328ea-c2c0-4f74-a449-5cdc976dcdb6',
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
expect(fetchMock.lastCall()?.[0]).toContain('/teams/');
|
||||
expect(fetchMock.lastCall()?.[1]?.body).toEqual(
|
||||
@@ -84,16 +76,12 @@ describe('CardCreateTeam', () => {
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCardCreateTeam();
|
||||
|
||||
renderModal();
|
||||
const teamNameInput = screen.getByLabelText('Team name');
|
||||
const createButton = screen.getByText('Create the team');
|
||||
|
||||
await user.type(teamNameInput, 'Existing Team');
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/This name is already used for another group/i),
|
||||
@@ -106,16 +94,12 @@ describe('CardCreateTeam', () => {
|
||||
body: {},
|
||||
status: 500,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCardCreateTeam();
|
||||
|
||||
renderModal();
|
||||
const teamNameInput = screen.getByLabelText('Team name');
|
||||
const createButton = screen.getByText('Create the team');
|
||||
|
||||
await user.type(teamNameInput, 'Server Error Team');
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -123,7 +107,6 @@ describe('CardCreateTeam', () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(fetchMock.calls()).toHaveLength(1);
|
||||
expect(fetchMock.lastCall()?.[0]).toContain('/teams/');
|
||||
expect(fetchMock.lastCall()?.[1]?.body).toEqual(
|
||||
@@ -134,16 +117,12 @@ describe('CardCreateTeam', () => {
|
||||
it('disables create button when API request is pending', async () => {
|
||||
// Never resolves
|
||||
fetchMock.post('end:teams/', new Promise(() => {}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderCardCreateTeam();
|
||||
|
||||
renderModal();
|
||||
const teamNameInput = screen.getByLabelText('Team name');
|
||||
const createButton = screen.getByText('Create the team');
|
||||
|
||||
await user.type(teamNameInput, 'Pending Team');
|
||||
await user.click(createButton);
|
||||
|
||||
expect(createButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1 @@
|
||||
export * from './CardCreateTeam';
|
||||
export * from './TeamInfo';
|
||||
export * from './TeamLayout';
|
||||
export * from './TeamView';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button, DataGrid } from '@openfun/cunningham-react';
|
||||
import { useRouter as useNavigate } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Text } from '@/components';
|
||||
import { TeamsOrdering, useTeams } from '@/features/teams/team-management';
|
||||
|
||||
interface TeamsListViewProps {
|
||||
querySearch: string;
|
||||
}
|
||||
|
||||
export function TeamsListView({ querySearch }: TeamsListViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useNavigate();
|
||||
const { data, isLoading } = useTeams({
|
||||
ordering: TeamsOrdering.BY_CREATED_ON_DESC,
|
||||
});
|
||||
const teams = React.useMemo(() => data || [], [data]);
|
||||
|
||||
const filteredTeams = React.useMemo(() => {
|
||||
if (!querySearch) {
|
||||
return teams;
|
||||
}
|
||||
const lower = querySearch.toLowerCase();
|
||||
return teams.filter((team) => team.name.toLowerCase().includes(lower));
|
||||
}, [teams, querySearch]);
|
||||
|
||||
return (
|
||||
<div role="listbox">
|
||||
{filteredTeams && filteredTeams.length ? (
|
||||
<DataGrid
|
||||
aria-label="listboxTeams"
|
||||
rows={filteredTeams}
|
||||
columns={[
|
||||
{
|
||||
field: 'name',
|
||||
headerName: t('Team'),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'members',
|
||||
headerName: t('Member count'),
|
||||
enableSorting: false,
|
||||
renderCell: ({ row }) => row.accesses.length,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
renderCell: ({ row }) => (
|
||||
<Button
|
||||
color="tertiary"
|
||||
onClick={() => router.push(`/teams/${row.id}`)}
|
||||
aria-label={t('View team details')}
|
||||
>
|
||||
<span>{t('Manage')}</span>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : null}
|
||||
{(!filteredTeams || !filteredTeams.length) && (
|
||||
<Text $align="center" $size="small">
|
||||
{t('No teams exist.')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { useRouter as useNavigate } from 'next/navigation';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { TextErrors } from '@/components/TextErrors';
|
||||
import {
|
||||
MailDomainsLayout,
|
||||
useMailDomain,
|
||||
} from '@/features/mail-domains/domains';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const MailDomainAccessesPage: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
|
||||
if (router?.query?.slug && typeof router.query.slug !== 'string') {
|
||||
throw new Error('Invalid mail domain slug');
|
||||
}
|
||||
|
||||
const { slug } = router.query;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { error, isError, isLoading } = useMailDomain({ slug: String(slug) });
|
||||
|
||||
if (error?.status === 404) {
|
||||
navigate.replace(`/404`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isError && error) {
|
||||
return <TextErrors causes={error?.cause} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box $align="center" $justify="center" $height="100%">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
MailDomainAccessesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MailDomainsLayout>{page}</MailDomainsLayout>;
|
||||
};
|
||||
|
||||
export default MailDomainAccessesPage;
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Button, Input, Tooltip } from '@openfun/cunningham-react';
|
||||
import { Button, Input } from '@openfun/cunningham-react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ModalAddMailDomain } from '@/features/mail-domains/domains/components/ModalAddMailDomain';
|
||||
import { MailDomainsListView } from '@/features/mail-domains/domains/components/panel/MailDomainsListView';
|
||||
@@ -12,8 +11,6 @@ import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { userData } = useAuthStore();
|
||||
const can_create = userData?.abilities?.mailboxes.can_create;
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
@@ -106,32 +103,12 @@ const Page: NextPageWithLayout = () => {
|
||||
<Box className="block md:hidden" $css="margin-bottom: 10px;"></Box>
|
||||
|
||||
<Box>
|
||||
{can_create ? (
|
||||
<Button data-testid="button-new-domain" onClick={openModal}>
|
||||
{t('Add a mail domain')}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip content={t("You don't have the correct access right")}>
|
||||
<div>
|
||||
<Button
|
||||
data-testid="button-new-domain"
|
||||
onClick={openModal}
|
||||
disabled={!can_create}
|
||||
>
|
||||
{t('Add a mail domain')}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button data-testid="button-new-domain" onClick={openModal}>
|
||||
{t('Add a mail domain')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{!can_create && (
|
||||
<p style={{ textAlign: 'center' }}>
|
||||
{t('Click on mailbox to view details')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MailDomainsListView querySearch={searchValue} />
|
||||
{isModalOpen && <ModalAddMailDomain closeModal={closeModal} />}
|
||||
</Box>
|
||||
|
||||
@@ -6,12 +6,8 @@ import { ReactElement } from 'react';
|
||||
import { Box } from '@/components';
|
||||
import { TextErrors } from '@/components/TextErrors';
|
||||
import { MemberGrid } from '@/features/teams/member-management';
|
||||
import {
|
||||
Role,
|
||||
TeamInfo,
|
||||
TeamLayout,
|
||||
useTeam,
|
||||
} from '@/features/teams/team-management';
|
||||
import { Role, TeamView, useTeam } from '@/features/teams/team-management';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
@@ -59,14 +55,14 @@ const Team = ({ id }: TeamProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TeamInfo team={team} currentRole={currentRole} />
|
||||
<TeamView team={team} currentRole={currentRole} />
|
||||
<MemberGrid team={team} currentRole={currentRole} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <TeamLayout>{page}</TeamLayout>;
|
||||
return <MainLayout backgroundColor="grey">{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { CardCreateTeam, TeamLayout } from '@/features/teams/team-management';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return (
|
||||
<Box $padding="large" $justify="center" $align="start" $height="inherit">
|
||||
<CardCreateTeam />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <TeamLayout>{page}</TeamLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Button, Input, Tooltip } from '@openfun/cunningham-react';
|
||||
import { useRouter as useNavigate } from 'next/navigation';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ModalCreateTeam } from '@/features/teams/team-management/components/ModalCreateTeam';
|
||||
import { TeamsListView } from '@/features/teams/team-management/components/TeamsListView';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useNavigate();
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||
const { userData } = useAuthStore();
|
||||
const can_create = userData?.abilities?.teams.can_create ?? false;
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
@@ -27,6 +27,14 @@ const Page: NextPageWithLayout = () => {
|
||||
setSearchValue('');
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box aria-label="Teams panel" className="container">
|
||||
<Box
|
||||
@@ -97,10 +105,7 @@ const Page: NextPageWithLayout = () => {
|
||||
|
||||
<Box>
|
||||
{can_create ? (
|
||||
<Button
|
||||
data-testid="button-new-team"
|
||||
onClick={() => void router.push('/teams/create')}
|
||||
>
|
||||
<Button data-testid="button-new-team" onClick={openModal}>
|
||||
{t('Create a new team')}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -108,7 +113,7 @@ const Page: NextPageWithLayout = () => {
|
||||
<div>
|
||||
<Button
|
||||
data-testid="button-new-team"
|
||||
onClick={() => void router.push('/teams/create')}
|
||||
onClick={openModal}
|
||||
disabled={!can_create}
|
||||
>
|
||||
{t('Create a new team')}
|
||||
@@ -126,6 +131,7 @@ const Page: NextPageWithLayout = () => {
|
||||
)}
|
||||
|
||||
<TeamsListView querySearch={searchValue} />
|
||||
{isModalOpen && <ModalCreateTeam closeModal={closeModal} />}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,6 @@ export const createTeam = async (
|
||||
browserName: string,
|
||||
length: number,
|
||||
) => {
|
||||
const panel = page.getByLabel('Teams panel').first();
|
||||
const buttonCreateHomepage = page.getByRole('button', {
|
||||
name: 'Create a new team',
|
||||
});
|
||||
@@ -51,17 +50,10 @@ export const createTeam = async (
|
||||
const randomTeams = randomName(teamName, browserName, length);
|
||||
|
||||
for (let i = 0; i < randomTeams.length; i++) {
|
||||
if (i == 0) {
|
||||
// for the first team, we need to click on the button in the homepage
|
||||
await buttonCreateHomepage.click();
|
||||
} else {
|
||||
// for the other teams, we need to click on the button in the panel of the detail view
|
||||
await panel.getByRole('button', { name: 'Add a team' }).click();
|
||||
}
|
||||
await buttonCreateHomepage.click();
|
||||
await page.getByText('Team name').fill(randomTeams[i]);
|
||||
await expect(buttonCreate).toBeEnabled();
|
||||
await buttonCreate.click();
|
||||
await expect(panel.locator('li').getByText(randomTeams[i])).toBeVisible();
|
||||
}
|
||||
|
||||
return randomTeams;
|
||||
@@ -70,7 +62,7 @@ export const createTeam = async (
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
role: 'Administration' | 'Owner' | 'Member',
|
||||
role: 'Administrator' | 'Owner' | 'Member',
|
||||
fillText: string = 'test',
|
||||
) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
@@ -94,11 +86,12 @@ export const addNewMember = async (
|
||||
await page.getByRole('option', { name: users[index].name }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('radio', { name: role }).click();
|
||||
await page.getByRole('combobox', { name: /role/i }).click();
|
||||
await page.getByRole('option', { name: role ? role : /Owner/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Add to group' }).click();
|
||||
await page.getByRole('button', { name: 'Add to team' }).click();
|
||||
|
||||
const table = page.getByLabel('List members card').getByRole('table');
|
||||
const table = page.getByRole('table');
|
||||
|
||||
await expect(table.getByText(users[index].name)).toBeVisible();
|
||||
await expect(
|
||||
|
||||
@@ -238,7 +238,7 @@ test.describe('Mail domain create mailbox', () => {
|
||||
await inputLastName.fill('Doe');
|
||||
await inputLocalPart.fill('john.doe');
|
||||
|
||||
await expect(page.locator('span').getByText('@domain.fr')).toBeVisible();
|
||||
await expect(page.getByText('@domain.fr', { exact: true })).toBeVisible();
|
||||
await inputSecondaryEmailAddress.fill('john.doe@mail.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
@@ -97,6 +97,7 @@ test.describe('Mail domain', () => {
|
||||
});
|
||||
|
||||
await page.goto('/mail-domains/unknown-domain.fr');
|
||||
await page.waitForURL('/404/');
|
||||
await expect(
|
||||
page.getByText(
|
||||
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
|
||||
|
||||
@@ -101,6 +101,8 @@ test.describe('Add Mail Domains', () => {
|
||||
test('checks 404 on mail-domains/[slug] page', async ({ page }) => {
|
||||
await page.goto('/mail-domains/unknown-domain');
|
||||
|
||||
await page.waitForURL('/404/');
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
|
||||
|
||||
@@ -24,7 +24,7 @@ test.describe('Members Create', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Add to group' }),
|
||||
page.getByRole('button', { name: 'Add to team' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible();
|
||||
});
|
||||
@@ -38,7 +38,7 @@ test.describe('Members Create', () => {
|
||||
|
||||
await page.getByLabel('Add members to the team').click();
|
||||
|
||||
await expect(page.getByRole('radio', { name: 'Owner' })).toBeHidden();
|
||||
const roleSelect = page.getByRole('combobox', { name: /role/i });
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the team/);
|
||||
|
||||
@@ -52,6 +52,8 @@ test.describe('Members Create', () => {
|
||||
|
||||
await page.getByRole('option', { name: users[0].name }).click();
|
||||
|
||||
await expect(roleSelect).toBeVisible();
|
||||
|
||||
// Select user 2
|
||||
await inputSearch.fill('test');
|
||||
await page.getByRole('option', { name: users[1].name }).click();
|
||||
@@ -78,11 +80,11 @@ test.describe('Members Create', () => {
|
||||
await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
|
||||
|
||||
// Check roles are displayed
|
||||
await expect(page.getByText(/Choose a role/)).toBeVisible();
|
||||
await expect(page.getByRole('radio', { name: 'Member' })).toBeChecked();
|
||||
await expect(page.getByRole('radio', { name: 'Owner' })).toBeVisible();
|
||||
await expect(page.getByText(/Choose a role/i)).toBeVisible();
|
||||
await expect(roleSelect).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: /Owner/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('radio', { name: 'Administration' }),
|
||||
page.getByRole('option', { name: /Administrator/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -115,7 +117,8 @@ test.describe('Members Create', () => {
|
||||
await page.getByRole('option', { name: users[0].name }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('radio', { name: 'Administration' }).click();
|
||||
await page.getByRole('combobox', { name: /Role/i }).click();
|
||||
await page.getByRole('option', { name: /Administrator/i }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -126,23 +129,23 @@ test.describe('Members Create', () => {
|
||||
response.url().includes('/accesses/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Add to group' }).click();
|
||||
await page.getByRole('button', { name: 'Add to team' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
expect(responseCreateInvitation.status()).toBe(201);
|
||||
|
||||
// Check member added
|
||||
await expect(
|
||||
page.getByText(`Member ${users[0].name} added to the team`),
|
||||
).toBeVisible();
|
||||
const responseAddMember = await responsePromiseAddMember;
|
||||
expect(responseAddMember.ok()).toBeTruthy();
|
||||
expect(responseAddMember.status()).toBe(201);
|
||||
|
||||
const table = page.getByLabel('List members card').getByRole('table');
|
||||
await expect(table.getByText(users[0].name)).toBeVisible();
|
||||
await expect(table.getByText('Administration')).toBeVisible();
|
||||
await expect(table).toContainText(users[0].name);
|
||||
await expect(table).toContainText('Administration');
|
||||
});
|
||||
|
||||
test('it try to add twice the same user', async ({ page, browserName }) => {
|
||||
@@ -164,20 +167,21 @@ test.describe('Members Create', () => {
|
||||
await page.getByRole('option', { name: users[0].name }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('radio', { name: 'Owner' }).click();
|
||||
await page.getByRole('combobox', { name: /role/i }).click();
|
||||
await page.getByRole('option', { name: /Owner/i }).click();
|
||||
|
||||
const responsePromiseAddMember = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/accesses/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Add to group' }).click();
|
||||
await page.getByRole('button', { name: 'Add to team' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(`Member ${users[0].name} added to the team`),
|
||||
).toBeVisible();
|
||||
const responseAddMember = await responsePromiseAddMember;
|
||||
expect(responseAddMember.ok()).toBeTruthy();
|
||||
expect(responseAddMember.status()).toBe(201);
|
||||
|
||||
await page.getByLabel('Add members to the team').click();
|
||||
|
||||
@@ -202,19 +206,20 @@ test.describe('Members Create', () => {
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('radio', { name: 'Owner' }).click();
|
||||
await page.getByRole('combobox', { name: /role/i }).click();
|
||||
await page.getByRole('option', { name: /Owner/i }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Add to group' }).click();
|
||||
await page.getByRole('button', { name: 'Add to team' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
expect(responseCreateInvitation.status()).toBe(201);
|
||||
|
||||
await page.getByLabel('Add members to the team').click();
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ test.describe('Members Delete', () => {
|
||||
}) => {
|
||||
await createTeam(page, 'member-delete-1', browserName, 1);
|
||||
|
||||
const table = page.getByLabel('List members card').getByRole('table');
|
||||
const table = page.getByRole('table');
|
||||
|
||||
const cells = table.getByRole('row').nth(1).getByRole('cell');
|
||||
await expect(cells.nth(1)).toHaveText('E2E Group Member');
|
||||
await cells.nth(4).getByLabel('Member options').click();
|
||||
await expect(cells.nth(0)).toContainText('E2E Group Member');
|
||||
await cells.nth(2).getByLabel('Member options').click();
|
||||
await page.getByLabel('Open the modal to delete this member').click();
|
||||
|
||||
await expect(
|
||||
@@ -44,9 +44,9 @@ test.describe('Members Delete', () => {
|
||||
// find row where regexp match the name
|
||||
const cells = table
|
||||
.getByRole('row')
|
||||
.filter({ hasText: 'E2E Group Member' })
|
||||
.filter({ hasText: /E2E Group Member/i })
|
||||
.getByRole('cell');
|
||||
await cells.nth(4).getByLabel('Member options').click();
|
||||
await cells.nth(2).getByLabel('Member options').click();
|
||||
await page.getByLabel('Open the modal to delete this member').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove from the group' }).click();
|
||||
@@ -70,7 +70,7 @@ test.describe('Members Delete', () => {
|
||||
.getByRole('row')
|
||||
.filter({ hasText: username })
|
||||
.getByRole('cell');
|
||||
await cells.nth(4).getByLabel('Member options').click();
|
||||
await cells.nth(2).getByLabel('Member options').click();
|
||||
await page.getByLabel('Open the modal to delete this member').click();
|
||||
|
||||
await expect(
|
||||
@@ -84,7 +84,7 @@ test.describe('Members Delete', () => {
|
||||
test('it deletes admin member', async ({ page, browserName }) => {
|
||||
await createTeam(page, 'member-delete-4', browserName, 1);
|
||||
|
||||
const username = await addNewMember(page, 0, 'Administration');
|
||||
const username = await addNewMember(page, 0, 'Administrator');
|
||||
|
||||
const table = page.getByLabel('List members card').getByRole('table');
|
||||
|
||||
@@ -93,7 +93,7 @@ test.describe('Members Delete', () => {
|
||||
.getByRole('row')
|
||||
.filter({ hasText: username })
|
||||
.getByRole('cell');
|
||||
await cells.nth(4).getByLabel('Member options').click();
|
||||
await cells.nth(2).getByLabel('Member options').click();
|
||||
await page.getByLabel('Open the modal to delete this member').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove from the group' }).click();
|
||||
@@ -118,12 +118,13 @@ test.describe('Members Delete', () => {
|
||||
.getByRole('row')
|
||||
.filter({ hasText: 'E2E Group Member' })
|
||||
.getByRole('cell');
|
||||
await myCells.nth(4).getByLabel('Member options').click();
|
||||
await myCells.nth(2).getByLabel('Open the member options modal').click();
|
||||
|
||||
// Change role to Admin
|
||||
await page.getByText('Update role').click();
|
||||
const radioGroup = page.getByLabel('Radio buttons to update the roles');
|
||||
await radioGroup.getByRole('radio', { name: 'Administration' }).click();
|
||||
|
||||
await page.getByRole('combobox', { name: /Role/i }).click();
|
||||
await page.getByRole('option', { name: /Administrator/i }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
const cells = table
|
||||
@@ -146,20 +147,20 @@ test.describe('Members Delete', () => {
|
||||
.getByRole('row')
|
||||
.filter({ hasText: 'E2E Group Member' })
|
||||
.getByRole('cell');
|
||||
await myCells.nth(4).getByLabel('Member options').click();
|
||||
await myCells.nth(2).getByLabel('Member options').click();
|
||||
|
||||
// Change role to Admin
|
||||
await page.getByText('Update role').click();
|
||||
const radioGroup = page.getByLabel('Radio buttons to update the roles');
|
||||
await radioGroup.getByRole('radio', { name: 'Administration' }).click();
|
||||
await page.getByRole('combobox', { name: /Role/i }).click();
|
||||
await page.getByRole('option', { name: /Administrator/i }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
const username = await addNewMember(page, 0, 'Administration', 'Monique');
|
||||
const username = await addNewMember(page, 0, 'Administrator', 'Monique');
|
||||
const cells = table
|
||||
.getByRole('row')
|
||||
.filter({ hasText: username })
|
||||
.getByRole('cell');
|
||||
await cells.nth(4).getByLabel('Member options').click();
|
||||
await cells.nth(2).getByLabel('Member options').click();
|
||||
await page.getByLabel('Open the modal to delete this member').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove from the group' }).click();
|
||||
|
||||
@@ -14,22 +14,21 @@ test.describe('Member Grid', () => {
|
||||
}) => {
|
||||
await createTeam(page, 'team-owner', browserName, 1);
|
||||
|
||||
const table = page.getByLabel('List members card').getByRole('table');
|
||||
const table = page.getByRole('table');
|
||||
|
||||
const thead = table.locator('thead');
|
||||
await expect(thead.getByText(/Names/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Emails/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Roles/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Member/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Role/i)).toBeVisible();
|
||||
|
||||
const cells = table.getByRole('row').nth(1).getByRole('cell');
|
||||
await expect(cells.nth(0).getByLabel('Member icon')).toBeVisible();
|
||||
await expect(cells.nth(1)).toHaveText(
|
||||
await expect(cells.nth(0)).toContainText(
|
||||
new RegExp(`E2E ${browserName}`, 'i'),
|
||||
);
|
||||
await expect(cells.nth(2)).toHaveText(
|
||||
await expect(cells.nth(0)).toContainText(
|
||||
`user-e2e-${browserName}@example.org`,
|
||||
);
|
||||
await expect(cells.nth(3)).toHaveText(/Owner/i);
|
||||
await expect(cells.nth(1)).toContainText(/Owner/i);
|
||||
});
|
||||
|
||||
test('try to update the owner role but cannot because it is the last owner', async ({
|
||||
@@ -38,13 +37,13 @@ test.describe('Member Grid', () => {
|
||||
}) => {
|
||||
await createTeam(page, 'team-owner-role', browserName, 1);
|
||||
|
||||
const table = page.getByLabel('List members card').getByRole('table');
|
||||
const table = page.getByRole('table');
|
||||
|
||||
const cells = table.getByRole('row').nth(1).getByRole('cell');
|
||||
await expect(cells.nth(1)).toHaveText(
|
||||
await expect(cells.nth(0)).toContainText(
|
||||
new RegExp(`E2E ${browserName}`, 'i'),
|
||||
);
|
||||
await cells.nth(4).getByLabel('Member options').click();
|
||||
await cells.nth(2).getByLabel('Member options').click();
|
||||
await page.getByText('Update role').click();
|
||||
|
||||
await expect(
|
||||
@@ -53,12 +52,11 @@ test.describe('Member Grid', () => {
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const radioGroup = page.getByLabel('Radio buttons to update the roles');
|
||||
|
||||
const radios = await radioGroup.getByRole('radio').all();
|
||||
for (const radio of radios) {
|
||||
await expect(radio).toBeDisabled();
|
||||
}
|
||||
const roleSelect = page.getByRole('combobox', { name: /Role/i });
|
||||
const cursor = await roleSelect.evaluate(
|
||||
(el) => getComputedStyle(el).cursor,
|
||||
);
|
||||
expect(cursor).toBe('not-allowed');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
|
||||
@@ -76,17 +76,11 @@ test.describe('Menu', () => {
|
||||
|
||||
const menu = page.locator('menu').first();
|
||||
|
||||
let buttonMenu = menu.getByLabel(`Teams button`);
|
||||
const buttonMenu = menu.getByLabel(`Teams button`);
|
||||
await buttonMenu.click();
|
||||
await expect(
|
||||
page.getByText('Click on team to view details').first(),
|
||||
).toBeVisible();
|
||||
|
||||
buttonMenu = menu.getByLabel(`Mail Domains`);
|
||||
await buttonMenu.click();
|
||||
await expect(
|
||||
page.getByText('Click on mailbox to view details').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test(`it checks that the menu is not displaying when all abilities`, async ({
|
||||
@@ -122,12 +116,12 @@ test.describe('Menu', () => {
|
||||
|
||||
const menu = page.locator('menu').first();
|
||||
|
||||
let buttonMenu = menu.getByLabel(`Teams button`);
|
||||
const buttonMenu = menu.getByLabel(`Teams button`);
|
||||
await buttonMenu.click();
|
||||
await expect(page.getByText('Create a new team').first()).toBeVisible();
|
||||
|
||||
buttonMenu = menu.getByLabel(`Mail Domains`);
|
||||
await buttonMenu.click();
|
||||
const buttonMenuMailDomain = menu.getByLabel(`Mail Domains`);
|
||||
await buttonMenuMailDomain.click();
|
||||
await expect(page.getByText('Add a mail domain').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,9 +25,9 @@ test.describe('Team', () => {
|
||||
level: 3,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Group details`)).toBeVisible();
|
||||
await expect(page.getByText(`Group members`)).toBeVisible();
|
||||
|
||||
await expect(page.getByText(`1 member`)).toBeVisible();
|
||||
// await expect(page.getByText(`1 member`)).toBeVisible();
|
||||
|
||||
const today = new Date(Date.now());
|
||||
const todayFormated = today.toLocaleDateString('en', {
|
||||
@@ -44,18 +44,18 @@ test.describe('Team', () => {
|
||||
test('it updates the team name', async ({ page, browserName }) => {
|
||||
await createTeam(page, 'team-update-name', browserName, 1);
|
||||
|
||||
await page.getByLabel(`Open the team options`).click();
|
||||
await page.getByRole('button', { name: `Update the team` }).click();
|
||||
await page.getByLabel('Open the team options').click();
|
||||
await page.getByRole('button', { name: 'Update the team' }).click();
|
||||
|
||||
const teamName = randomName('new-team-update-name', browserName, 1)[0];
|
||||
await page.getByText('New name...', { exact: true }).fill(teamName);
|
||||
await page.getByLabel('New name...').fill(teamName);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Validate the modification' })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText('The team has been updated.')).toBeVisible();
|
||||
await expect(page.getByText(`Group details`)).toBeVisible();
|
||||
await expect(page.getByText('Group members')).toBeVisible();
|
||||
});
|
||||
|
||||
test('sorts group members by search term', async ({
|
||||
|
||||
@@ -13,30 +13,12 @@ test.describe('Teams Create', () => {
|
||||
name: 'Create a new team',
|
||||
});
|
||||
await buttonCreateHomepage.click();
|
||||
await expect(buttonCreateHomepage).toBeHidden();
|
||||
|
||||
const card = page.getByLabel('Create new team card').first();
|
||||
const modal = page.getByRole('dialog')
|
||||
await expect(modal).getByText(/Create a new team/i).toBeVisible();
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(card.getByLabel('Team name')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
card.getByRole('heading', {
|
||||
name: 'Create a new group',
|
||||
level: 3,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
card.getByRole('button', {
|
||||
name: 'Create the team',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
card.getByRole('button', {
|
||||
name: 'Cancel',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(modal.getByRole('button', { name: 'Cancel' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks the cancel button interaction', async ({ page }) => {
|
||||
@@ -44,11 +26,10 @@ test.describe('Teams Create', () => {
|
||||
name: 'Create a new team',
|
||||
});
|
||||
await buttonCreateHomepage.click();
|
||||
await expect(buttonCreateHomepage).toBeHidden();
|
||||
|
||||
const card = page.getByLabel('Create new team card').first();
|
||||
const modal = page.getByRole('dialog');
|
||||
|
||||
await card
|
||||
await modal
|
||||
.getByRole('button', {
|
||||
name: 'Cancel',
|
||||
})
|
||||
@@ -72,17 +53,11 @@ test.describe('Teams Create', () => {
|
||||
|
||||
const elTeam = page.getByRole('heading', { name: teamName });
|
||||
await expect(elTeam).toBeVisible();
|
||||
|
||||
const panel = page.getByLabel('Teams panel').first();
|
||||
await panel.getByRole('button', { name: 'Add a team' }).click();
|
||||
await expect(elTeam).toBeHidden();
|
||||
|
||||
await panel.locator('li').getByText(teamName).click();
|
||||
await expect(elTeam).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks 404 on teams/[id] page', async ({ page }) => {
|
||||
await page.goto('/teams/some-unknown-team');
|
||||
await page.waitForURL('/404/');
|
||||
await expect(
|
||||
page.getByText(
|
||||
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
|
||||
|
||||
@@ -12,14 +12,26 @@ test.describe('Teams Delete', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createTeam(page, 'team-update-name-1', browserName, 1);
|
||||
const teamName = `team-update-name-1-${browserName}`;
|
||||
await createTeam(page, teamName, browserName, 1);
|
||||
|
||||
await page.getByLabel(`Open the team options`).click();
|
||||
await page.getByRole('button', { name: `Delete the team` }).click();
|
||||
await page.getByRole('button', { name: `Confirm deletion` }).click();
|
||||
await expect(page.getByText(`The team has been removed.`)).toBeVisible();
|
||||
// On est redirigé sur la page de la team
|
||||
await expect(page.getByRole('heading', { name: teamName })).toBeVisible();
|
||||
|
||||
// Ouvre le menu d'options de la team
|
||||
await page.getByLabel('Open the team options').click();
|
||||
await page
|
||||
.getByRole('button')
|
||||
.getByText(/Delete the team/i)
|
||||
.click();
|
||||
|
||||
// La modale s'ouvre, on confirme la suppression
|
||||
await page.getByRole('button', { name: 'Confirm deletion' }).click();
|
||||
|
||||
// Vérifie le toast et le retour à l'accueil
|
||||
await expect(page.getByText('The team has been removed.')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: `Create a new team` }),
|
||||
page.getByRole('button', { name: 'Create a new team' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -27,27 +39,34 @@ test.describe('Teams Delete', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createTeam(page, 'team-update-name-2', browserName, 1);
|
||||
const teamName = `team-update-name-2-${browserName}`;
|
||||
await createTeam(page, teamName, browserName, 1);
|
||||
|
||||
await addNewMember(page, 0, 'Owner');
|
||||
|
||||
// Change role to Administration
|
||||
// Change role to Administration via le select custom
|
||||
const table = page.getByLabel('List members card').getByRole('table');
|
||||
const myCells = table
|
||||
const myRow = table
|
||||
.getByRole('row')
|
||||
.filter({ hasText: new RegExp(`E2E ${browserName}`, 'i') })
|
||||
.getByRole('cell');
|
||||
await myCells.nth(4).getByLabel('Member options').click();
|
||||
.first();
|
||||
await myRow.getByLabel('Member options').click();
|
||||
|
||||
await page.getByText('Update role').click();
|
||||
const radioGroup = page.getByLabel('Radio buttons to update the roles');
|
||||
await radioGroup.getByRole('radio', { name: 'Administration' }).click();
|
||||
const roleSelect = page.getByRole('combobox', { name: /role/i });
|
||||
await roleSelect.click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
// Delete the team button should be hidden
|
||||
await page.getByLabel(`Open the team options`).click();
|
||||
await page.reload();
|
||||
|
||||
// On reste sur la page de la team, le bouton Delete the team ne doit pas être visible
|
||||
await page.getByLabel('Open the team options').click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: `Delete the team` }),
|
||||
page
|
||||
.getByRole('button')
|
||||
.getByText(/Delete the team/i)
|
||||
.first(),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -55,24 +74,42 @@ test.describe('Teams Delete', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createTeam(page, 'team-update-name-3', browserName, 1);
|
||||
const teamName = `team-update-name-3-${browserName}`;
|
||||
await createTeam(page, teamName, browserName, 1);
|
||||
|
||||
await addNewMember(page, 0, 'Owner');
|
||||
|
||||
// Change role to Administration
|
||||
// Change role à Administrator
|
||||
const table = page.getByLabel('List members card').getByRole('table');
|
||||
const myCells = table
|
||||
let myRow = table
|
||||
.getByRole('row')
|
||||
.filter({ hasText: new RegExp(`E2E ${browserName}`, 'i') })
|
||||
.getByRole('cell');
|
||||
await myCells.nth(4).getByLabel('Member options').click();
|
||||
.first();
|
||||
await myRow.getByLabel('Member options').click();
|
||||
|
||||
await page.getByText('Update role').click();
|
||||
const radioGroup = page.getByLabel('Radio buttons to update the roles');
|
||||
await radioGroup.getByRole('radio', { name: 'Member' }).click();
|
||||
let roleSelect = page.getByRole('combobox', { name: /role/i });
|
||||
await roleSelect.click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
// Option button should be hidden
|
||||
await expect(page.getByLabel(`Open the team options`)).toBeHidden();
|
||||
await page.reload();
|
||||
|
||||
// Refaire la manip pour passer à Member
|
||||
myRow = table
|
||||
.getByRole('row')
|
||||
.filter({ hasText: new RegExp(`E2E ${browserName}`, 'i') })
|
||||
.first();
|
||||
await myRow.getByLabel('Member options').click();
|
||||
await page.getByText('Update role').click();
|
||||
roleSelect = page.getByRole('combobox', { name: /role/i });
|
||||
await roleSelect.click();
|
||||
await page.getByRole('option', { name: 'Member' }).click();
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
// Le bouton d'options de la team ne doit plus être visible
|
||||
await expect(page.getByLabel('Open the team options')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createTeam, keyCloakSignIn } from './common';
|
||||
import { keyCloakSignIn } from './common';
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
@@ -14,18 +14,18 @@ test.describe('Teams Panel', () => {
|
||||
await expect(page.getByTestId('button-new-team')).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks the hover and selected state', async ({ page, browserName }) => {
|
||||
const panel = page.getByLabel('Teams panel').first();
|
||||
await createTeam(page, 'team-hover', browserName, 2);
|
||||
// test('checks the hover and selected state', async ({ page, browserName }) => {
|
||||
// const panel = page.getByLabel('Teams panel').first();
|
||||
// await createTeam(page, 'team-hover', browserName, 2);
|
||||
|
||||
const selectedTeam = panel.locator('li').nth(0);
|
||||
await expect(selectedTeam).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(133, 133, 246)',
|
||||
);
|
||||
// const selectedTeam = panel.locator('li').nth(0);
|
||||
// await expect(selectedTeam).toHaveCSS(
|
||||
// 'background-color',
|
||||
// 'rgb(133, 133, 246)',
|
||||
// );
|
||||
|
||||
const hoverTeam = panel.locator('li').nth(1);
|
||||
await hoverTeam.hover();
|
||||
await expect(hoverTeam).toHaveCSS('background-color', 'rgb(202, 202, 251)');
|
||||
});
|
||||
// const hoverTeam = panel.locator('li').nth(1);
|
||||
// await hoverTeam.hover();
|
||||
// await expect(hoverTeam).toHaveCSS('background-color', 'rgb(202, 202, 251)');
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -32,12 +32,10 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
webServer: {
|
||||
command: `cd ../.. && yarn app:${
|
||||
process.env.CI ? 'start -p ' : 'dev --port '
|
||||
} ${PORT}`,
|
||||
command: !process.env.CI ? `cd ../.. && yarn app:dev --port ${PORT}` : '',
|
||||
url: baseURL,
|
||||
timeout: 120 * 1000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
|
||||
@@ -73,6 +73,7 @@ backend:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: MAIL_PROVISIONING_API_CREDENTIALS
|
||||
TCHAP_ACCESS_TOKEN: service_account_key
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
|
||||
Reference in New Issue
Block a user