Compare commits

...

57 Commits

Author SHA1 Message Date
renovate[bot] f866529307 ⬆️(dependencies) update python dependencies 2026-03-23 01:05:24 +00:00
renovate[bot] 99eaec9653 ⬆️(dependencies) update next to v15.5.14 [SECURITY] 2026-03-20 10:51:17 +00:00
renovate[bot] 2ab3a67a44 ⬆️(dependencies) update next to v15.5.13 [SECURITY] 2026-03-20 10:30:25 +00:00
Marie PUPO JEAMMET 0fd29e82d5 🧑‍💻(makefile) build front when bootstraping
add frontend-dev to build command to rebuild front image
2026-03-19 11:39:14 +00:00
Eléonore 0776b7d95a 💄(ui-kit) regie v2 ui-kit (#1083)
update regie to ui-kit v2
2026-03-19 10:56:07 +01:00
Eléonore Voisin 19f7bb77c0 🚚(route) prioritize mail domains as default landing page
add line in changelog
2026-03-19 10:30:19 +01:00
Louis 83cae4036c (frontend) update test to expect /mail-domains/ as fallback redirect
The fallback now always redirects to /mail-domains/, so the test
with TEAMS_DISPLAY=true should expect /mail-domains/ instead of /teams/.
2026-03-19 10:30:19 +01:00
Louis ed07eecad4 🐛(frontend) fix fallback redirect to always go to /mail-domains/
The ternary was incorrectly inverted, causing users to be sent to
/teams/ when TEAMS_DISPLAY was off. Simplify to always redirect
to /mail-domains/ as the fallback.
2026-03-19 10:30:19 +01:00
Louis 46ba75b119 🚸(frontend) prioritize mail domains as default landing page
(made with ai, review carefully)
 Swap the redirect priority so that users landing on the root page
are directed to /mail-domains/ instead of /teams/ by default.
This applies both to the permission-based checks and the fallback
using the TEAMS_DISPLAY feature flag.
2026-03-19 10:30:19 +01:00
Louis 1b4d1043af 🍱(static) update logo in invitation email template (#1085)
Replace logo-footer-mail.png with an updated version in the
invitation email template. Remove unused logo-suite-numerique.png.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:43:29 +01:00
Eléonore bc458b3f68 (datagrid) add sort to mailboxes list + mail domain list (#1057)
add sort to mailboxes list + mail domain list

(invitations) allow delete invitations by an admin (#1052)

allow delete invitations mails domains access by an admin
2026-03-16 16:23:29 +00:00
renovate[bot] 5bc845172b ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] 2026-03-16 11:47:31 +00:00
Marie PUPO JEAMMET 8a35e862ff ♻️(tests) finish cleaning dimail token fixture
finish cleaning the fixtures returning valid
token as dimail
2026-03-13 15:20:04 +00:00
Marie PUPO JEAMMET bc0dbcb678 🩹(mailboxes) enforce mailboxes are lowercase
enforce lowercase for new mailboxes, upon creation
and when sending to dimail
2026-03-13 15:20:04 +00:00
Stephan Meijer 68ec85e94a ⬆️(ci) upgrade GitHub Actions workflow steps to latest versions
Update all GitHub Actions to their latest major versions for improved
performance, security patches, and Node.js runtime compatibility.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-03-13 14:47:35 +00:00
Marie PUPO JEAMMET ca754d5bcd ⬆️(security) upgrade tornado to fix CVE
Upgrade tornado to fix this CVE
https://github.com/tornadoweb/tornado/security/advisories/GHSA-qjxf-f2mg-c6mc
2026-03-13 14:29:15 +00:00
renovate[bot] 06df255072 ⬆️(dependencies) update python dependencies 2026-03-13 14:29:15 +00:00
Jacob Weisz da1a9e1d7a ✏️(typo) fix readme typo
Small fix of a typo in the README
2026-03-12 18:01:45 +00:00
Louis 74ada8fd6b 🐛(i18n) fix missing translations for status tag labels
(made with ai, review carefully)
Status labels in the Tag component ("enabled", "action required", etc.) were
not translated because `t(status)` used a dynamic variable that i18next-parser
cannot extract.

Replace with a static `translations` map using literal `t()` calls so the keys
are properly detected during extraction and sent to Crowdin for translation.

Co-authored-by: BEY Quentin <quentin.bey@mail.numerique.gouv.fr>
2026-03-12 17:33:22 +00:00
renovate[bot] 0c123bb9de ⬆️(dependencies) update django to v5.2.12 [SECURITY] 2026-03-12 17:17:47 +00:00
renovate[bot] 2a6367c3ac ⬆️(dependencies) update joserfc to v1.6.3 [SECURITY] 2026-03-12 17:17:47 +00:00
Quentin BEY 79bdc6606e ⬆️(dependencies) bump django-lasuite to 0.0.25
Required to use the django-lasuite custom admin.
2026-03-10 14:47:33 +01:00
Quentin BEY c53434411d 🔥(admin) switch to django-lasuite customization
The admin application override has been moved into
the django-lasuite library.
2026-02-26 09:21:56 +01:00
Marie PUPO JEAMMET b9c69e7c08 🚨(lint) fix linter warnings
fix linter warnings after ruff upgrade
2026-02-18 18:13:42 +00:00
renovate[bot] b1e0a24626 ⬆️(dependencies) update python dependencies 2026-02-18 18:13:42 +00:00
mjeammet a1b58fb864 🌐(i18n) update translated strings
Update translated files with new translations
2026-02-16 14:39:42 +00:00
Marie PUPO JEAMMET e1a8cc31f9 🔖(patch) release version 1.23.1
Update all version files and changelog for patch release.
2026-02-16 14:39:42 +00:00
Marie PUPO JEAMMET d0e5aa5952 🚚(exceptions) move invitation-related exception to core
move invitation-related from mailbox_manager to core, as
it will be useful for teams too
2026-02-13 11:09:17 +00:00
Marie PUPO JEAMMET 3b58fb7e1e (invitations) refresh invitations
invitations can now be refreshed, de-expiring
them or moving their expiration date further
in the future.
2026-02-13 11:09:17 +00:00
Marie PUPO JEAMMET 1e02ded7b8 ⬆️(dependencies) bump pillow and cryptography to fix CVEs
Bump with `uv lock --upgrade-package pillow` or `cryptography`
This fixes CVEs https://avd.aquasec.com/nvd/cve-2026-26007 and
https://avd.aquasec.com/nvd/cve-2026-25990
2026-02-12 15:18:31 +00:00
mjeammet 92fcb359c9 🌐(i18n) update translated strings
Update translated files with new translations
2026-02-12 15:18:31 +00:00
Marie PUPO JEAMMET 8c9b35ec47 🔖(minor) release version 1.23.0
Update all version files and changelog for minor release.
2026-02-12 15:18:31 +00:00
Marie PUPO JEAMMET 4e75d675f8 (stats) count aliases in stats endpoint
stats endpoint now return the count of aliases
2026-02-12 13:55:53 +00:00
Marie PUPO JEAMMET 1d4d40aad0 🧑‍💻(demo) add aliases to demo
add aliases to all domains in demo
2026-02-12 13:55:53 +00:00
Quentin BEY aaa9b27c61 🚸(email) we should ignore case when looking for existing emails
Our products mostly rely on email regardless of their case.

Next step would be to normalize email to lower case when storing
them in database (or sending them to dimail if not done yet).
2026-02-12 09:47:24 +01:00
elvoisin a29ef05d8b (front) add icon to button to configure a domain (#1054)
Added an icon to help understanding of the configuration button/tag
2026-02-11 15:39:42 +00:00
elvoisin 40c39bd9ba (invitations) allow delete invitations by an admin (#1052)
allow delete invitations mails domains access by an admin
2026-02-11 10:29:03 +01:00
Quentin BEY 59f9f54b34 💚(crowdin) fix upload job
Since the switch to `uv` we should not use pip.
2026-02-10 15:01:50 +01:00
Marie PUPO JEAMMET 7804583632 (tests) simplify mail domain invitations tests
just a quick simplification for our tests regarding domain invitations
2026-02-06 11:09:43 +00:00
Marie PUPO JEAMMET 63b8984eb2 🐛(domains) fix attemps to send invitations to existing users
Users can only search other users inside their own organization.
This leads to a bug where we try to invite an email already linked to an user
2026-02-06 11:09:43 +00:00
renovate[bot] 0df8f53037 ⬆️(dependencies) update next to v15.5.10 [SECURITY] 2026-02-06 10:41:06 +00:00
Marie PUPO JEAMMET 41084baa0f 🔧(docker) set python version to 3.14.2
set python version to 3.14.2 to match dependencies' expectations
2026-02-05 17:12:34 +00:00
renovate[bot] 32de0b9221 ⬆️(dependencies) update python dependencies 2026-02-05 17:12:34 +00:00
renovate[bot] 1384640a3a ⬆️(dependencies) update django to v5.2.11 [SECURITY] 2026-02-03 21:00:40 +00:00
elvoisin 569aff05a1 (front) add show invitations mails domains access (#1040)
* (front) add show invitations mails domains access

add show invitations to mails domains access

* (front) delete invitations mails domains access

add delete button for delete invitations to mails domains access
2026-02-03 16:06:18 +01:00
Quentin BEY b13f4db536 🔧(actions) migrate from pip to uv
Migrate usage of pip to uv in github actions. How python is setup is
also changed. Doing like this, we will just have to upgrade the python
version requirement in the pyproject file
2026-01-29 23:25:51 +01:00
Quentin BEY 8ace3099b9 🔧(backend) ignore .venv in compile messages command
We have to change the ignored files used in the compoilemessages
command. uv is using .venv directory and not venv
2026-01-29 23:17:03 +01:00
Quentin BEY 38d2125ecf 🏗️(core) migrate from pip to uv
We want to migrate our projects from pip to uv to take the benefits of
the lock file and have reproducible installations.
A first uv.lock file is comitted and the Dockerfile and compose are
modified to work with uv
2026-01-29 23:16:16 +01:00
Marie PUPO JEAMMET 6ae195b90c 🔥(cleanup) remove comment from permissions file
remote remaining commented line from previous PR's tests
2026-01-28 14:49:31 +00:00
Marie PUPO JEAMMET be64abb22f (invitations) can delete domain invitations
add delete method to domain viewset
2026-01-28 14:49:31 +00:00
mjeammet ca56eb0cac 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-26 11:30:57 +00:00
Marie PUPO JEAMMET 99ca34719f 🔖(patch) release version 1.22.2
Update all version files and changelog for patch release.
2026-01-26 11:30:57 +00:00
renovate[bot] 014fb62f95 ⬆️(dependencies) update lodash to v4.17.23 [SECURITY] 2026-01-26 10:48:52 +00:00
Marie PUPO JEAMMET 99433a6722 🐛(aliases) alias destination can be devnull@devnull
devnull@devnull is not considered a valid email address by django's
EmailFieldValidator but it's a special address in dimail's config.

Make "destination" a CharField instead of an EmailField to replace
validator and add devnull to allowlist.
2026-01-23 17:56:53 +00:00
Marie PUPO JEAMMET 5ebc88bcff 🔖(patch) release version 1.22.1
Update all version files and changelog for patch release.
2026-01-22 11:10:44 +00:00
Quentin BEY a65e61bd96 🔒️(organization) the first user is not admin
The first user of a organization is probably not an admin.
This was implemented for first tests but for now it's more
a security issue than something helpful.

FIXES #775
2026-01-22 11:26:58 +01:00
Marie PUPO JEAMMET b8beb56135 🐛(admin) fix broken alias import
fix admin action to sync aliases of a given domain
2026-01-21 17:30:30 +00:00
194 changed files with 19794 additions and 8033 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
@@ -48,7 +48,7 @@ jobs:
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+11 -11
View File
@@ -20,19 +20,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
working-directory: src/backend
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -45,10 +45,10 @@ jobs:
- name: generate pot files
working-directory: src/backend
run: |
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
DJANGO_CONFIGURATION=Build uv run python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+8 -8
View File
@@ -20,16 +20,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
@@ -37,7 +37,7 @@ jobs:
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -50,10 +50,10 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -61,7 +61,7 @@ jobs:
- name: Setup Node.js
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node_version }}
@@ -79,7 +79,7 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Call argocd github webhook
run: |
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Docker meta
id: meta
@@ -48,7 +48,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Docker meta
id: meta
@@ -85,7 +85,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Docker meta
id: meta
+41 -32
View File
@@ -22,7 +22,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: show
@@ -49,7 +49,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
@@ -59,7 +59,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -73,10 +73,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -86,7 +86,7 @@ jobs:
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
@@ -96,10 +96,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -113,10 +113,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -136,7 +136,7 @@ jobs:
shardTotal: [4]
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set services env variables
run: |
@@ -144,7 +144,7 @@ jobs:
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -152,14 +152,14 @@ jobs:
fail-on-cache-miss: true
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
uses: actions/cache@v5
id: cache-build
with:
path: src/frontend/apps/desk/out/
@@ -220,19 +220,22 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: uv run pylint .
test-back:
runs-on: ubuntu-latest
@@ -266,29 +269,35 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Install Python
uses: actions/setup-python@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the dependencies
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
run: uv run pytest -n 2
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
+53 -1
View File
@@ -8,6 +8,54 @@ and this project adheres to
## [Unreleased]
### Fixed
- ✨(mailboxes) enforce lowercase on mailboxes
- 🐛(i18n) fix missing translations for status tag labels
- 🚚(route) prioritize mail domains as default landing page
### Changed
- 🍱(static) update logo in invitation email template #1085
- ✨(uiV2) use Lasuite UI kit, new layout
## [1.23.1] - 2026-02-16
- ✨(invitations) refresh expired invitations
## [1.23.0] - 2026-02-12
### Added
- ✨(demo) add aliases to demo #1050
- ✨(front) add icon to button to configure a domain
- ✨(datagrid) add sort to mailboxes list + mail domain list
- ✨(invitations) allow delete invitations mails domains access by an admin
- ✨(front) delete invitations mails domains access
- ✨(front) add show invitations mails domains access #1040
- ✨(invitations) can delete domain invitations
### Fixed
- 🐛(domains) fix attemps to send invitations to existing users #953
### Changed
- 🚸(email) we should ignore case when looking for existing emails #1056
- 🏗️(core) migrate from pip to uv
- ✨(front) add show invitations mails domains access #1040
## [1.22.2] - 2026-01-26
### Fixed
- 🐛(aliases) authorize special domain devnull in alias destinations #1029
## [1.22.1] - 2026-01-21
- 🔒️(organization) the first user is not admin #776
- 🐛(admin) fix broken alias import #1021
## [1.22.0] - 2026-01-19
### Added
@@ -475,7 +523,11 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/suitenumerique/people/compare/v1.22.0...main
[unreleased]: https://github.com/suitenumerique/people/compare/v1.23.1...main
[1.23.1]: https://github.com/suitenumerique/people/releases/v1.23.1
[1.23.0]: https://github.com/suitenumerique/people/releases/v1.23.0
[1.22.2]: https://github.com/suitenumerique/people/releases/v1.22.2
[1.22.1]: https://github.com/suitenumerique/people/releases/v1.22.1
[1.22.0]: https://github.com/suitenumerique/people/releases/v1.22.0
[1.21.0]: https://github.com/suitenumerique/people/releases/v1.21.0
[1.20.0]: https://github.com/suitenumerique/people/releases/v1.20.0
+36 -24
View File
@@ -1,10 +1,7 @@
# Django People
# ---- base image to inherit from ----
FROM python:3.13.11-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
FROM python:3.14.2-alpine AS base
# Upgrade system packages to install security updates
RUN apk update && \
@@ -13,13 +10,26 @@ RUN apk update && \
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Copy required python dependencies
COPY ./src/backend /builder
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
RUN mkdir /install && \
pip install --prefix=/install .
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
@@ -42,14 +52,13 @@ RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy people application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
@@ -80,14 +89,18 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy people application (see .dockerignore)
COPY ./src/backend /app/
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
# Ensure the uv venv is used at runtime
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
@@ -102,10 +115,9 @@ USER root:root
# Install psql
RUN apk add postgresql-client
# Uninstall people and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y people
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.10,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Restore the un-privileged user running the application
ARG DOCKER_USER
+7 -7
View File
@@ -107,7 +107,7 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev
@$(COMPOSE) build app-dev frontend-dev
@$(COMPOSE) build dimail
.PHONY: build
@@ -215,7 +215,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@@ -241,21 +241,21 @@ resetdb: ## flush database and create a superuser "admin"
.PHONY: resetdb
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
cp --update=none env.d/development/common.dist env.d/development/common
env.d/development/france:
cp -n env.d/development/france.dist env.d/development/france
cp --update=none env.d/development/france.dist env.d/development/france
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
cp --update=none env.d/development/postgresql.dist env.d/development/postgresql
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
cp --update=none env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
# -- Internationalization
env.d/development/crowdin:
cp -n env.d/development/crowdin.dist env.d/development/crowdin
cp --update=none env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from Crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
+1 -1
View File
@@ -1,6 +1,6 @@
# People
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
People is an application to handle users and teams, and distribute permissions across [La Suite](https://lasuite.numerique.gouv.fr/).
It is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).
+2
View File
@@ -42,6 +42,7 @@ services:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_healthy
@@ -117,6 +118,7 @@ services:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_healthy
-1
View File
@@ -1 +0,0 @@
"""People custom admin site."""
-9
View File
@@ -1,9 +0,0 @@
"""Custom Django admin site application configuration."""
from django.contrib.admin.apps import AdminConfig
class PeopleAdminConfig(AdminConfig):
"""Declare our custom Django admin site."""
default_site = "admin.sites.PeopleAdminSite"
-15
View File
@@ -1,15 +0,0 @@
"""Custom Django admin site for the People app."""
from django.conf import settings
from django.contrib import admin
class PeopleAdminSite(admin.AdminSite):
"""People custom admin site."""
def each_context(self, request):
"""Add custom context to the admin site."""
return super().each_context(request) | {
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
}
@@ -1,13 +0,0 @@
{% extends "admin/base_site.html" %}
{% block extrastyle %}{{ block.super }}
<style>
html[data-theme="light"], :root {
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
}
ul.messagelist li.info {
background-color: #EEEEEE;
}
</style>
{% endblock %}
+1
View File
@@ -621,6 +621,7 @@ class StatView(views.APIView):
status=enums.MailDomainStatusChoices.ENABLED
).count(),
"mailboxes": domains_models.Mailbox.objects.count(),
"aliases": domains_models.Alias.objects.count(),
}
return response.Response(context)
@@ -17,8 +17,6 @@ from core.models import (
AccountService,
Contact,
Organization,
OrganizationAccess,
OrganizationRoleChoices,
)
logger = logging.getLogger(__name__)
@@ -131,15 +129,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
user = super().create_user(claims | {"organization": organization})
if organization_created:
# Warning: we may remove this behavior in the near future when we
# add a feature to claim the organization ownership.
OrganizationAccess.objects.create(
organization=organization,
user=user,
role=OrganizationRoleChoices.ADMIN,
)
# Initiate the user's profile
Contact.objects.create(
owner=user,
+18
View File
@@ -0,0 +1,18 @@
"""
Custom exceptions for mailbox manager app
"""
from django.utils.translation import gettext_lazy as _
from rest_framework import status
from rest_framework.exceptions import APIException
class EmailAlreadyKnownException(APIException):
"""Exception raised when trying to create a user with an already existing email address."""
status_code = status.HTTP_201_CREATED
default_detail = _(
"Email already known. Invitation not sent but access created instead."
)
default_code = "email already known"
@@ -11,7 +11,7 @@ def update_team_paths(apps, schema_editor):
steplen = 5
# Initialize NumConv with the specified custom alphabet
converter = NumConv(len(alphabet), alphabet)
converter = NumConv(alphabet)
nodes = Team.objects.all().order_by("created_at")
for i, node in enumerate(nodes, 1):
+13 -9
View File
@@ -32,6 +32,7 @@ from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
from core.exceptions import EmailAlreadyKnownException
from core.plugins.registry import registry as plugin_hooks_registry
from core.utils.webhooks import webhooks_synchronizer
from core.validators import get_field_validators_from_setting
@@ -813,7 +814,7 @@ class Team(MP_Node, BaseModel):
except AttributeError:
try:
role = self.accesses.filter(user=user).values("role")[0]["role"]
except (TeamAccess.DoesNotExist, IndexError):
except TeamAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
@@ -900,7 +901,7 @@ class TeamAccess(BaseModel):
role = self._meta.model.objects.filter(
team=self.team_id, user=user
).values("role")[0]["role"]
except (self._meta.model.DoesNotExist, IndexError):
except self._meta.model.DoesNotExist, IndexError:
role = None
is_team_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
@@ -996,19 +997,22 @@ class BaseInvitation(BaseModel):
super().clean()
# Check if a user already exists for the provided email
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
if User.objects.filter(email__iexact=self.email).exists():
raise EmailAlreadyKnownException
def refresh(self):
"""A simple way to refresh invitation and move expiration date."""
self.clean()
self.updated_at = timezone.now()
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
if not self.created_at:
if not self.updated_at:
return None
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
return timezone.now() > (self.updated_at + validity_duration)
def _get_mail_subject(self):
"""Get the subject of the invitation."""
@@ -1096,7 +1100,7 @@ class Invitation(BaseInvitation):
role = self.team.accesses.filter(user=user).values("role")[0][
"role"
]
except (self._meta.model.DoesNotExist, IndexError):
except self._meta.model.DoesNotExist, IndexError:
role = None
can_delete = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
+1 -1
View File
@@ -9,7 +9,7 @@ plugins_urlpatterns = []
for app in settings.INSTALLED_PLUGINS:
try:
plugins_urlpatterns.append(path("", include(f"{app}.urls")))
except (ImportError, AttributeError):
except ImportError, AttributeError:
# Skip if app doesn't have urls.py
continue
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -391,6 +391,8 @@ def test_authentication_getter_new_user_with_registration_id_new_organization(
assert user.organization.domain_list == expected_domain_list
assert user.organization.registration_id_list == expected_registration_id_list
assert models.OrganizationAccess.objects.filter(user=user).exists() is False
def test_authentication_getter_existing_user_via_email_update_organization(
django_assert_num_queries, monkeypatch
+10 -8
View File
@@ -11,7 +11,6 @@ from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories as domains_factories
from mailbox_manager import models as domains_models
pytestmark = pytest.mark.django_db
@@ -21,18 +20,20 @@ def test_api_stats__anonymous(django_assert_num_queries):
domains_factories.MailDomainEnabledFactory.create_batch(5)
core_factories.TeamFactory.create_batch(3)
domains_factories.AliasFactory.create_batch(2)
# clear cache to allow stats count
cache.clear()
with django_assert_num_queries(5):
with django_assert_num_queries(6):
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_users": 0,
"mau": 0,
"active_domains": 5,
"active_domains": 7,
"mailboxes": 0,
"teams": 3,
"aliases": 2,
}
# no new request made due to caching
with django_assert_num_queries(0):
@@ -41,9 +42,10 @@ def test_api_stats__anonymous(django_assert_num_queries):
assert response.json() == {
"total_users": 0,
"mau": 0,
"active_domains": 5,
"active_domains": 7,
"mailboxes": 0,
"teams": 3,
"aliases": 2,
}
@@ -58,10 +60,9 @@ def test_api_stats__expected_count():
core_factories.TeamFactory.create_batch(3)
domains_factories.MailDomainFactory.create_batch(1)
domains_factories.MailDomainEnabledFactory.create_batch(2)
domains_factories.MailboxFactory.create_batch(
10, domain=domains_models.MailDomain.objects.all()[1]
)
enabled_domain, _ = domains_factories.MailDomainEnabledFactory.create_batch(2)
domains_factories.MailboxFactory.create_batch(10, domain=enabled_domain)
domains_factories.AliasFactory.create_batch(2, domain=enabled_domain)
# clear cache to allow stats count
cache.clear()
@@ -73,4 +74,5 @@ def test_api_stats__expected_count():
"active_domains": 2,
"mailboxes": 10,
"teams": 3,
"aliases": 2,
}
@@ -171,10 +171,11 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
def test_api_team_invitations__list__anonymous_user():
@@ -16,6 +16,7 @@ from faker import Faker
from freezegun import freeze_time
from core import factories, models
from core.exceptions import EmailAlreadyKnownException
pytestmark = pytest.mark.django_db
@@ -48,6 +49,17 @@ def test_models_invitations_team_required():
factories.InvitationFactory(team=None)
def test_models_invitations_email_case_insensitive_duplicate_check():
"""The email validation should be case-insensitive when checking for existing users."""
# Create a user with a lowercase email
factories.UserFactory(email="john.doe@example.com")
# Try to create an invitation with different case
# This should raise the same exception as if the email was exactly the same
with pytest.raises(EmailAlreadyKnownException):
factories.InvitationFactory(email="John.Doe@Example.COM")
def test_models_invitations_team_should_be_team_instance():
"""The "team" field should be a team instance."""
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
+2
View File
@@ -5,4 +5,6 @@ NB_OBJECTS = {
"teams": 100,
"max_users_per_team": 100,
"domains": 10,
"mailboxes_per_domain": 2,
"aliases_per_domain": 2,
}
@@ -288,14 +288,12 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
)
queue.flush()
domains = mailbox_models.MailDomain.objects.all()
with Timeit(stdout, "Creating accesses to domains"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
for domain in domains:
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domain_id,
domain=domain,
user_id=random.choice(users_ids),
role=models.RoleChoices.OWNER,
)
@@ -304,11 +302,8 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
queue.flush()
with Timeit(stdout, "Creating mailboxes"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
for i in range(random.randint(1, 10)):
for domain in domains:
for i in range(defaults.NB_OBJECTS["mailboxes_per_domain"]):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}{i}"
@@ -318,7 +313,7 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
first_name=first_name,
last_name=last_name,
local_part=local_part,
domain_id=domain_id,
domain=domain,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
dn_email=local_part,
@@ -327,6 +322,19 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
queue.flush()
with Timeit(stdout, "Creating aliases"):
for domain in domains:
for _i in range(defaults.NB_OBJECTS["aliases_per_domain"]):
queue.push(
mailbox_models.Alias(
local_part=fake.word(),
destination=fake.email(),
domain=domain,
)
)
queue.flush()
with Timeit(stdout, "Creating specific users"):
# ⚠️ Warning: this users also need to be created in the keycloak
# realm.json AND the OIDC setting to fallback on user email
@@ -361,7 +369,7 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
queue.push(user_with_mail)
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domains_ids[0],
domain=domains[0],
user_id=user_with_mail.pk,
role=role,
)
@@ -387,7 +395,7 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
)
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domains_ids[0],
domain=domains[0],
user_id=team_mail_user.pk,
role=domain_role,
)
@@ -414,21 +422,20 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
role=MailDomainRoleChoices.ADMIN,
)
# Many mailboxes domain
many_boxes_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="many-boxes-domain.com",
# Many objects domain
many_objects_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="many-objects-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@mbd.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=many_boxes_domain,
domain=many_objects_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=many_boxes_domain,
domain=many_objects_domain,
email="people@people.world",
role=MailDomainRoleChoices.OWNER,
)
@@ -436,15 +443,22 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}"
mailbox_models.Mailbox.objects.create(
domain=many_boxes_domain,
domain=many_objects_domain,
first_name=first_name,
last_name=last_name,
local_part=local_part,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
password=make_password(None), # unusable password
dn_email=f"{local_part}@{many_boxes_domain}",
dn_email=f"{local_part}@{many_objects_domain}",
)
mailbox_models.Alias.objects.create(
local_part=fake.word(),
destination=fake.email(),
domain=many_objects_domain,
)
# OIDC configuration
@@ -20,6 +20,8 @@ TEST_NB_OBJECTS = {
"teams": 100,
"max_users_per_team": 5,
"domains": 10,
"mailboxes_per_domain": 2,
"aliases_per_domain": 2,
}
@@ -33,8 +35,8 @@ def test_commands_create_demo(settings):
call_command("create_demo")
# Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
# 3 user with team rights
# 3 user with domain rights
# 3 users with team rights
# 3 users with domain rights
# 3 x 3 user with both rights
assert models.User.objects.count() == TEST_NB_OBJECTS["users"] + 3 + 3 + 3 + 9
@@ -55,6 +57,16 @@ def test_commands_create_demo(settings):
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 2
)
# TEST_NB_OBJECTS["domains"]*TEST_NB_OBJECTS["mailboxes_per_domain"] = 20
# + 30 in the many-object-domain
# + 1 mailbox for user-e2e@example.com
# = 51
assert mailbox_models.Mailbox.objects.count() == 51
# TEST_NB_OBJECTS["domains"]*TEST_NB_OBJECTS["mailboxes_per_alias"] = 20
# + 30 in the many-object-domain
assert mailbox_models.Alias.objects.count() == 50
def test_commands_createsuperuser():
"""
+321 -342
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-19 20:15+0000\n"
"PO-Revision-Date: 2026-01-19 20:18\n"
"POT-Creation-Date: 2026-02-13 11:10+0000\n"
"PO-Revision-Date: 2026-02-16 14:19\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,590 +17,336 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: build/lib/core/admin.py:62 core/admin.py:62
#: core/admin.py:62
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:73 core/admin.py:73
#: core/admin.py:73
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:85 core/admin.py:85
#: core/admin.py:85
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:124 core/admin.py:124
#: core/admin.py:124
msgid "User"
msgstr ""
#: build/lib/core/admin.py:226 core/admin.py:226
#: core/admin.py:226
msgid "Run post creation plugins"
msgstr ""
#: build/lib/core/admin.py:234 core/admin.py:234
#: core/admin.py:234
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
#: build/lib/core/apps.py:65 core/apps.py:65
#: core/apps.py:65
msgid "People core application"
msgstr ""
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
#: core/authentication/backends.py:102
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
#: core/authentication/backends.py:122
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
#: core/authentication/backends.py:170 core/authentication/backends.py:172
msgid "Invalid authorization header."
msgstr ""
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
#: core/authentication/backends.py:177
msgid "Invalid api key."
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
#: core/enums.py:24
msgid "Failure"
msgstr ""
#: build/lib/core/enums.py:25 build/lib/mailbox_manager/enums.py:21
#: build/lib/mailbox_manager/enums.py:31 core/enums.py:25
#: mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
#: core/enums.py:25 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr ""
#: build/lib/core/enums.py:26 core/enums.py:26
#: core/enums.py:26
msgid "Success"
msgstr ""
#: build/lib/core/models.py:76 core/models.py:76
#: core/exceptions.py:16
msgid "Email already known. Invitation not sent but access created instead."
msgstr ""
#: core/models.py:77
msgid "Member"
msgstr ""
#: build/lib/core/models.py:77 build/lib/core/models.py:89
#: build/lib/mailbox_manager/enums.py:14 core/models.py:77 core/models.py:89
#: mailbox_manager/enums.py:14
#: core/models.py:78 core/models.py:90 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
#: core/models.py:78 mailbox_manager/enums.py:15
#: core/models.py:79 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:101 core/models.py:101
#: core/models.py:102
msgid "id"
msgstr ""
#: build/lib/core/models.py:102 core/models.py:102
#: core/models.py:103
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:108 core/models.py:108
#: core/models.py:109
msgid "created at"
msgstr ""
#: build/lib/core/models.py:109 core/models.py:109
#: core/models.py:110
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:114 core/models.py:114
#: core/models.py:115
msgid "updated at"
msgstr ""
#: build/lib/core/models.py:115 core/models.py:115
#: core/models.py:116
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:155
msgid "full name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: core/models.py:156
msgid "short name"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: core/models.py:159
msgid "notes"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: core/models.py:161
msgid "contact information"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:162
msgid "A JSON object containing the contact information"
msgstr ""
#: build/lib/core/models.py:175 core/models.py:175
#: core/models.py:176
msgid "contact"
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: core/models.py:177
msgid "contacts"
msgstr ""
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
#: build/lib/mailbox_manager/models.py:54 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:54
#: core/models.py:251 core/models.py:365 core/models.py:511 core/models.py:1125
#: mailbox_manager/models.py:56
msgid "name"
msgstr ""
#: build/lib/core/models.py:252 core/models.py:252
#: core/models.py:253
msgid "audience id"
msgstr ""
#: build/lib/core/models.py:257 core/models.py:257
#: core/models.py:258
msgid "service provider"
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
#: core/models.py:259
msgid "service providers"
msgstr ""
#: build/lib/core/models.py:372 core/models.py:372
#: core/models.py:373
msgid "registration ID list"
msgstr ""
#: build/lib/core/models.py:379 core/models.py:379
#: core/models.py:380
msgid "domain list"
msgstr ""
#: build/lib/core/models.py:386 core/models.py:386
#: core/models.py:387
msgid "metadata"
msgstr ""
#: build/lib/core/models.py:387 core/models.py:387
#: core/models.py:388
msgid "A JSON object containing the organization metadata"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
#: core/models.py:535
#: core/models.py:398 core/models.py:536
msgid "active"
msgstr ""
#: build/lib/core/models.py:403 core/models.py:403
#: core/models.py:404
msgid "organization"
msgstr ""
#: build/lib/core/models.py:404 core/models.py:404
#: core/models.py:405
msgid "organizations"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: core/models.py:412
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
#: build/lib/core/models.py:495 core/models.py:495
#: core/models.py:496
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: build/lib/core/models.py:501 core/models.py:501
#: core/models.py:502
msgid "sub"
msgstr ""
#: build/lib/core/models.py:503 core/models.py:503
#: core/models.py:504
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: build/lib/core/models.py:509 build/lib/core/models.py:973 core/models.py:509
#: core/models.py:973
#: core/models.py:510 core/models.py:974
msgid "email address"
msgstr ""
#: build/lib/core/models.py:515 core/models.py:515
#: core/models.py:516
msgid "language"
msgstr ""
#: build/lib/core/models.py:516 core/models.py:516
#: core/models.py:517
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:522 core/models.py:522
#: core/models.py:523
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:525 core/models.py:525
#: core/models.py:526
msgid "device"
msgstr ""
#: build/lib/core/models.py:527 core/models.py:527
#: core/models.py:528
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:530 core/models.py:530
#: core/models.py:531
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:532 core/models.py:532
#: core/models.py:533
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:538 core/models.py:538
#: core/models.py:539
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:557 core/models.py:557
#: core/models.py:558
msgid "user"
msgstr ""
#: build/lib/core/models.py:558 core/models.py:558
#: core/models.py:559
msgid "users"
msgstr ""
#: build/lib/core/models.py:696 core/models.py:696
#: core/models.py:697
msgid "Organization/user relation"
msgstr ""
#: build/lib/core/models.py:697 core/models.py:697
#: core/models.py:698
msgid "Organization/user relations"
msgstr ""
#: build/lib/core/models.py:702 core/models.py:702
#: core/models.py:703
msgid "This user is already in this organization."
msgstr ""
#: build/lib/core/models.py:761 core/models.py:761
#: core/models.py:762
msgid "external_id"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
#: core/models.py:763
msgid "Team external UUID for synchronization with external systems"
msgstr ""
#: build/lib/core/models.py:787 core/models.py:787
#: core/models.py:788
msgid "is visible for all SP"
msgstr ""
#: build/lib/core/models.py:789 core/models.py:789
#: core/models.py:790
msgid "Whether this team is visible to all service providers."
msgstr ""
#: build/lib/core/models.py:797 core/models.py:797
#: core/models.py:798
msgid "Team"
msgstr ""
#: build/lib/core/models.py:798 core/models.py:798
#: core/models.py:799
msgid "Teams"
msgstr ""
#: build/lib/core/models.py:849 core/models.py:849
#: core/models.py:850
msgid "Team/user relation"
msgstr ""
#: build/lib/core/models.py:850 core/models.py:850
#: core/models.py:851
msgid "Team/user relations"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: core/models.py:856
msgid "This user is already in this team."
msgstr ""
#: build/lib/core/models.py:941 core/models.py:941
#: core/models.py:942
msgid "url"
msgstr ""
#: build/lib/core/models.py:942 core/models.py:942
#: core/models.py:943
msgid "secret"
msgstr ""
#: build/lib/core/models.py:956 core/models.py:956
#: core/models.py:957
msgid "Team webhook"
msgstr ""
#: build/lib/core/models.py:957 core/models.py:957
#: core/models.py:958
msgid "Team webhooks"
msgstr ""
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/core/models.py:1015 core/models.py:1015
#: core/models.py:1019
msgid "Invitation to join La Régie!"
msgstr ""
#: build/lib/core/models.py:1061 core/models.py:1061
#: core/models.py:1065
msgid "Team invitation"
msgstr ""
#: build/lib/core/models.py:1062 core/models.py:1062
#: core/models.py:1066
msgid "Team invitations"
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
#: core/models.py:1079
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr ""
#: build/lib/core/models.py:1123 core/models.py:1123
#: core/models.py:1127
msgid "api key"
msgstr ""
#: build/lib/core/models.py:1128 core/models.py:1128
#: core/models.py:1132
msgid "allowed scopes"
msgstr ""
#: build/lib/core/models.py:1129 core/models.py:1129
#: core/models.py:1133
msgid "Allowed scopes for this service"
msgstr ""
#: build/lib/core/models.py:1134 core/models.py:1134
#: core/models.py:1138
msgid "Account service"
msgstr ""
#: build/lib/core/models.py:1135 core/models.py:1135
#: core/models.py:1139
msgid "Account services"
msgstr ""
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:32 build/lib/mailbox_manager/admin.py:71
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:39 mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:46 build/lib/mailbox_manager/admin.py:90
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:90
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:51 mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:78 mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s. %(imported_aliases)imported aliases: %(mailboxes)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:95 mailbox_manager/admin.py:95
msgid "Check and update status from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:112 mailbox_manager/admin.py:112
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:125 mailbox_manager/admin.py:125
msgid "Check domains done with success."
msgstr ""
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:128 mailbox_manager/admin.py:128
msgid "No domain updated."
msgstr ""
#: build/lib/mailbox_manager/admin.py:135 mailbox_manager/admin.py:135
msgid "Check domain failed for:"
msgstr ""
#: build/lib/mailbox_manager/admin.py:143 mailbox_manager/admin.py:143
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:148 mailbox_manager/admin.py:148
msgid "Fetch domain expected config from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:162 mailbox_manager/admin.py:162
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:168 mailbox_manager/admin.py:168
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:174 mailbox_manager/admin.py:174
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:179 mailbox_manager/admin.py:179
msgid "Send pending mailboxes to dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:195 mailbox_manager/admin.py:195
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:201 mailbox_manager/admin.py:201
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:207 mailbox_manager/admin.py:207
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
#: build/lib/mailbox_manager/enums.py:13 mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
#: build/lib/mailbox_manager/enums.py:22 build/lib/mailbox_manager/enums.py:32
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr ""
#: build/lib/mailbox_manager/enums.py:23 build/lib/mailbox_manager/enums.py:33
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr ""
#: build/lib/mailbox_manager/enums.py:24 build/lib/mailbox_manager/enums.py:34
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr ""
#: build/lib/mailbox_manager/enums.py:25 mailbox_manager/enums.py:25
msgid "Action required"
msgstr ""
#: build/lib/mailbox_manager/models.py:33 mailbox_manager/models.py:33
msgid "[La Suite] Your domain is ready"
msgstr ""
#: build/lib/mailbox_manager/models.py:38 mailbox_manager/models.py:38
msgid "[La Suite] Your domain requires action"
msgstr ""
#: build/lib/mailbox_manager/models.py:43 mailbox_manager/models.py:43
msgid "[La Suite] Your domain has failed"
msgstr ""
#: build/lib/mailbox_manager/models.py:69 mailbox_manager/models.py:69
msgid "support email"
msgstr ""
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
msgid "last check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
msgid "A JSON object containing the last health check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
msgid "expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
msgid "A JSON object containing the expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
msgid "Mail domain"
msgstr ""
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
msgid "Mail domains"
msgstr ""
#: build/lib/mailbox_manager/models.py:202 mailbox_manager/models.py:202
msgid "User/mail domain relation"
msgstr ""
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
msgid "User/mail domain relations"
msgstr ""
#: build/lib/mailbox_manager/models.py:276 mailbox_manager/models.py:276
msgid "local_part"
msgstr ""
#: build/lib/mailbox_manager/models.py:290 mailbox_manager/models.py:290
msgid "secondary email address"
msgstr ""
#: build/lib/mailbox_manager/models.py:301 mailbox_manager/models.py:301
msgid "email"
msgstr ""
#: build/lib/mailbox_manager/models.py:307 mailbox_manager/models.py:307
msgid "Mailbox"
msgstr ""
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
msgid "Mailboxes"
msgstr ""
#: build/lib/mailbox_manager/models.py:350 mailbox_manager/models.py:350
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: build/lib/mailbox_manager/models.py:407 mailbox_manager/models.py:407
msgid "Mail domain invitation"
msgstr ""
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
msgid "Mail domain invitations"
msgstr ""
#: build/lib/mailbox_manager/models.py:420 mailbox_manager/models.py:420
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: build/lib/mailbox_manager/models.py:463 mailbox_manager/models.py:463
msgid "destination address"
msgstr ""
#: build/lib/mailbox_manager/models.py:474 mailbox_manager/models.py:474
msgid "Alias"
msgstr ""
#: build/lib/mailbox_manager/models.py:475 mailbox_manager/models.py:475
msgid "Aliases"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:296
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:307
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr ""
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr ""
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:195
#: core/templates/mail/text/maildomain_action_required.txt:5
msgid "Some actions are required on your domain"
@@ -892,3 +638,236 @@ msgstr ""
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
msgstr ""
#: mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr ""
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr ""
#: mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr ""
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr ""
#: mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr ""
#: mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr ""
#: mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr ""
#: mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr ""
#: mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr ""
#: mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr ""
#: mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr ""
#: mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr ""
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr ""
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr ""
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr ""
#: mailbox_manager/models.py:35
msgid "[La Suite] Your domain is ready"
msgstr ""
#: mailbox_manager/models.py:40
msgid "[La Suite] Your domain requires action"
msgstr ""
#: mailbox_manager/models.py:45
msgid "[La Suite] Your domain has failed"
msgstr ""
#: mailbox_manager/models.py:71
msgid "support email"
msgstr ""
#: mailbox_manager/models.py:75
msgid "last check details"
msgstr ""
#: mailbox_manager/models.py:76
msgid "A JSON object containing the last health check details"
msgstr ""
#: mailbox_manager/models.py:81
msgid "expected config"
msgstr ""
#: mailbox_manager/models.py:82
msgid "A JSON object containing the expected config"
msgstr ""
#: mailbox_manager/models.py:87
msgid "Mail domain"
msgstr ""
#: mailbox_manager/models.py:88
msgid "Mail domains"
msgstr ""
#: mailbox_manager/models.py:204
msgid "User/mail domain relation"
msgstr ""
#: mailbox_manager/models.py:205
msgid "User/mail domain relations"
msgstr ""
#: mailbox_manager/models.py:278
msgid "local_part"
msgstr ""
#: mailbox_manager/models.py:292
msgid "secondary email address"
msgstr ""
#: mailbox_manager/models.py:303
msgid "email"
msgstr ""
#: mailbox_manager/models.py:309
msgid "Mailbox"
msgstr ""
#: mailbox_manager/models.py:310
msgid "Mailboxes"
msgstr ""
#: mailbox_manager/models.py:352
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: mailbox_manager/models.py:409
msgid "Mail domain invitation"
msgstr ""
#: mailbox_manager/models.py:410
msgid "Mail domain invitations"
msgstr ""
#: mailbox_manager/models.py:439
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: mailbox_manager/models.py:483
msgid "destination address"
msgstr ""
#: mailbox_manager/models.py:501
msgid "Alias"
msgstr ""
#: mailbox_manager/models.py:502
msgid "Aliases"
msgstr ""
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr ""
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr ""
#: people/settings.py:159
msgid "English"
msgstr ""
#: people/settings.py:160
msgid "French"
msgstr ""
+322 -343
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-19 20:15+0000\n"
"PO-Revision-Date: 2026-01-19 20:18\n"
"POT-Creation-Date: 2026-02-13 11:10+0000\n"
"PO-Revision-Date: 2026-02-16 14:19\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,590 +17,336 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: build/lib/core/admin.py:62 core/admin.py:62
#: core/admin.py:62
msgid "Personal info"
msgstr "Informations personnelles"
#: build/lib/core/admin.py:73 core/admin.py:73
#: core/admin.py:73
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:85 core/admin.py:85
#: core/admin.py:85
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:124 core/admin.py:124
#: core/admin.py:124
msgid "User"
msgstr "Utilisateur"
#: build/lib/core/admin.py:226 core/admin.py:226
#: core/admin.py:226
msgid "Run post creation plugins"
msgstr "Exécuter les plugins de post-création"
#: build/lib/core/admin.py:234 core/admin.py:234
#: core/admin.py:234
msgid "Post creation plugins have been run for the selected organizations."
msgstr "Les plugins de post-création ont été exécutés pour les organisations sélectionnées."
#: build/lib/core/apps.py:65 core/apps.py:65
#: core/apps.py:65
msgid "People core application"
msgstr "Application cœur de People"
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
#: core/authentication/backends.py:102
msgid "Claims contained no recognizable user identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
#: core/authentication/backends.py:122
msgid "Claims contained no recognizable organization identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
#: core/authentication/backends.py:170 core/authentication/backends.py:172
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
#: core/authentication/backends.py:177
msgid "Invalid api key."
msgstr "Clé API invalide."
#: build/lib/core/enums.py:24 core/enums.py:24
#: core/enums.py:24
msgid "Failure"
msgstr "En échec"
#: build/lib/core/enums.py:25 build/lib/mailbox_manager/enums.py:21
#: build/lib/mailbox_manager/enums.py:31 core/enums.py:25
#: mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
#: core/enums.py:25 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr "En attente"
#: build/lib/core/enums.py:26 core/enums.py:26
#: core/enums.py:26
msgid "Success"
msgstr "Réussi"
#: build/lib/core/models.py:76 core/models.py:76
#: core/exceptions.py:16
msgid "Email already known. Invitation not sent but access created instead."
msgstr "Adresse email déjà connue. Aucune invitation envoyée mais accès créé à la place."
#: core/models.py:77
msgid "Member"
msgstr "Membre"
#: build/lib/core/models.py:77 build/lib/core/models.py:89
#: build/lib/mailbox_manager/enums.py:14 core/models.py:77 core/models.py:89
#: mailbox_manager/enums.py:14
#: core/models.py:78 core/models.py:90 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
#: core/models.py:78 mailbox_manager/enums.py:15
#: core/models.py:79 mailbox_manager/enums.py:15
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/models.py:101 core/models.py:101
#: core/models.py:102
msgid "id"
msgstr "id"
#: build/lib/core/models.py:102 core/models.py:102
#: core/models.py:103
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: build/lib/core/models.py:108 core/models.py:108
#: core/models.py:109
msgid "created at"
msgstr "créé le"
#: build/lib/core/models.py:109 core/models.py:109
#: core/models.py:110
msgid "date and time at which a record was created"
msgstr "date et heure de création de l'enregistrement"
#: build/lib/core/models.py:114 core/models.py:114
#: core/models.py:115
msgid "updated at"
msgstr "mis à jour le"
#: build/lib/core/models.py:115 core/models.py:115
#: core/models.py:116
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:155
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:155 core/models.py:155
#: core/models.py:156
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:158 core/models.py:158
#: core/models.py:159
msgid "notes"
msgstr "notes"
#: build/lib/core/models.py:160 core/models.py:160
#: core/models.py:161
msgid "contact information"
msgstr "informations de contact"
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:162
msgid "A JSON object containing the contact information"
msgstr "Un objet JSON contenant les informations de contact"
#: build/lib/core/models.py:175 core/models.py:175
#: core/models.py:176
msgid "contact"
msgstr "contact"
#: build/lib/core/models.py:176 core/models.py:176
#: core/models.py:177
msgid "contacts"
msgstr "contacts"
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
#: build/lib/mailbox_manager/models.py:54 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:54
#: core/models.py:251 core/models.py:365 core/models.py:511 core/models.py:1125
#: mailbox_manager/models.py:56
msgid "name"
msgstr "nom"
#: build/lib/core/models.py:252 core/models.py:252
#: core/models.py:253
msgid "audience id"
msgstr "ID d'audience"
#: build/lib/core/models.py:257 core/models.py:257
#: core/models.py:258
msgid "service provider"
msgstr "fournisseur de services"
#: build/lib/core/models.py:258 core/models.py:258
#: core/models.py:259
msgid "service providers"
msgstr "fournisseurs de services"
#: build/lib/core/models.py:372 core/models.py:372
#: core/models.py:373
msgid "registration ID list"
msgstr "liste d'identifiants d'inscription"
#: build/lib/core/models.py:379 core/models.py:379
#: core/models.py:380
msgid "domain list"
msgstr "liste des domaines"
#: build/lib/core/models.py:386 core/models.py:386
#: core/models.py:387
msgid "metadata"
msgstr "métadonnées"
#: build/lib/core/models.py:387 core/models.py:387
#: core/models.py:388
msgid "A JSON object containing the organization metadata"
msgstr "Un objet JSON contenant les métadonnées de l'organisation"
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
#: core/models.py:535
#: core/models.py:398 core/models.py:536
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:403 core/models.py:403
#: core/models.py:404
msgid "organization"
msgstr "organisation"
#: build/lib/core/models.py:404 core/models.py:404
#: core/models.py:405
msgid "organizations"
msgstr "organisations"
#: build/lib/core/models.py:411 core/models.py:411
#: core/models.py:412
msgid "An organization must have at least a registration ID or a domain."
msgstr "Une organisation doit avoir au moins un ID d'enregistrement ou un domaine."
#: build/lib/core/models.py:495 core/models.py:495
#: core/models.py:496
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des chiffres et des caractères @/./+/-/_."
#: build/lib/core/models.py:501 core/models.py:501
#: core/models.py:502
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:503 core/models.py:503
#: core/models.py:504
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ seulement."
#: build/lib/core/models.py:509 build/lib/core/models.py:973 core/models.py:509
#: core/models.py:973
#: core/models.py:510 core/models.py:974
msgid "email address"
msgstr "adresse email"
#: build/lib/core/models.py:515 core/models.py:515
#: core/models.py:516
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:516 core/models.py:516
#: core/models.py:517
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:522 core/models.py:522
#: core/models.py:523
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:525 core/models.py:525
#: core/models.py:526
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:527 core/models.py:527
#: core/models.py:528
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:530 core/models.py:530
#: core/models.py:531
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:532 core/models.py:532
#: core/models.py:533
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:538 core/models.py:538
#: core/models.py:539
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:557 core/models.py:557
#: core/models.py:558
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:558 core/models.py:558
#: core/models.py:559
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:696 core/models.py:696
#: core/models.py:697
msgid "Organization/user relation"
msgstr "Relation organisation/utilisateur"
#: build/lib/core/models.py:697 core/models.py:697
#: core/models.py:698
msgid "Organization/user relations"
msgstr "Relations organisation/utilisateur"
#: build/lib/core/models.py:702 core/models.py:702
#: core/models.py:703
msgid "This user is already in this organization."
msgstr "Cet utilisateur est déjà dans cette organisation."
#: build/lib/core/models.py:761 core/models.py:761
#: core/models.py:762
msgid "external_id"
msgstr "ID externe"
#: build/lib/core/models.py:762 core/models.py:762
#: core/models.py:763
msgid "Team external UUID for synchronization with external systems"
msgstr "UUID externe de l'équipe pour la synchronisation avec des systèmes externes"
#: build/lib/core/models.py:787 core/models.py:787
#: core/models.py:788
msgid "is visible for all SP"
msgstr "est visible pour tous les fournisseurs de service"
#: build/lib/core/models.py:789 core/models.py:789
#: core/models.py:790
msgid "Whether this team is visible to all service providers."
msgstr "Si cette équipe est visible pour tous les fournisseurs de services."
#: build/lib/core/models.py:797 core/models.py:797
#: core/models.py:798
msgid "Team"
msgstr "Équipe"
#: build/lib/core/models.py:798 core/models.py:798
#: core/models.py:799
msgid "Teams"
msgstr "Équipes"
#: build/lib/core/models.py:849 core/models.py:849
#: core/models.py:850
msgid "Team/user relation"
msgstr "Relation équipe/utilisateur"
#: build/lib/core/models.py:850 core/models.py:850
#: core/models.py:851
msgid "Team/user relations"
msgstr "Relations équipe/utilisateur"
#: build/lib/core/models.py:855 core/models.py:855
#: core/models.py:856
msgid "This user is already in this team."
msgstr "Cet utilisateur est déjà dans cette équipe."
#: build/lib/core/models.py:941 core/models.py:941
#: core/models.py:942
msgid "url"
msgstr "url"
#: build/lib/core/models.py:942 core/models.py:942
#: core/models.py:943
msgid "secret"
msgstr "secret"
#: build/lib/core/models.py:956 core/models.py:956
#: core/models.py:957
msgid "Team webhook"
msgstr "Webhook d'équipe"
#: build/lib/core/models.py:957 core/models.py:957
#: core/models.py:958
msgid "Team webhooks"
msgstr "Webhooks d'équipe"
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: build/lib/core/models.py:1015 core/models.py:1015
#: core/models.py:1019
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie !"
#: build/lib/core/models.py:1061 core/models.py:1061
#: core/models.py:1065
msgid "Team invitation"
msgstr "Invitation d'équipe"
#: build/lib/core/models.py:1062 core/models.py:1062
#: core/models.py:1066
msgid "Team invitations"
msgstr "Invitations d'équipe"
#: build/lib/core/models.py:1075 core/models.py:1075
#: core/models.py:1079
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr "[La Suite] Vous avez été invité(e) à être %(role)s d'un groupe"
#: build/lib/core/models.py:1123 core/models.py:1123
#: core/models.py:1127
msgid "api key"
msgstr "Clé d'API"
#: build/lib/core/models.py:1128 core/models.py:1128
#: core/models.py:1132
msgid "allowed scopes"
msgstr "périmètres autorisés"
#: build/lib/core/models.py:1129 core/models.py:1129
#: core/models.py:1133
msgid "Allowed scopes for this service"
msgstr "Périmètres d'application autorisés pour ce service"
#: build/lib/core/models.py:1134 core/models.py:1134
#: core/models.py:1138
msgid "Account service"
msgstr "Compte de service"
#: build/lib/core/models.py:1135 core/models.py:1135
#: core/models.py:1139
msgid "Account services"
msgstr "Comptes de service"
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr "Importer les emails depuis dimail"
#: build/lib/mailbox_manager/admin.py:32 build/lib/mailbox_manager/admin.py:71
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:39 mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
#: build/lib/mailbox_manager/admin.py:46 build/lib/mailbox_manager/admin.py:90
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:90
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
#: build/lib/mailbox_manager/admin.py:51 mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:78 mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s. %(imported_aliases)imported aliases: %(mailboxes)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:95 mailbox_manager/admin.py:95
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
#: build/lib/mailbox_manager/admin.py:112 mailbox_manager/admin.py:112
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr "- %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:125 mailbox_manager/admin.py:125
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
#, python-format
msgid "Domains updated: %(domains)s"
msgstr "Domaines mis à jour : %(domains)s"
#: build/lib/mailbox_manager/admin.py:128 mailbox_manager/admin.py:128
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
#: build/lib/mailbox_manager/admin.py:135 mailbox_manager/admin.py:135
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
#: build/lib/mailbox_manager/admin.py:143 mailbox_manager/admin.py:143
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
#: build/lib/mailbox_manager/admin.py:148 mailbox_manager/admin.py:148
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: build/lib/mailbox_manager/admin.py:162 mailbox_manager/admin.py:162
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:168 mailbox_manager/admin.py:168
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:174 mailbox_manager/admin.py:174
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: build/lib/mailbox_manager/admin.py:179 mailbox_manager/admin.py:179
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: build/lib/mailbox_manager/admin.py:195 mailbox_manager/admin.py:195
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: build/lib/mailbox_manager/admin.py:201 mailbox_manager/admin.py:201
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: build/lib/mailbox_manager/admin.py:207 mailbox_manager/admin.py:207
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
#: build/lib/mailbox_manager/enums.py:13 mailbox_manager/enums.py:13
msgid "Viewer"
msgstr "Lecteur"
#: build/lib/mailbox_manager/enums.py:22 build/lib/mailbox_manager/enums.py:32
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr "Actif"
#: build/lib/mailbox_manager/enums.py:23 build/lib/mailbox_manager/enums.py:33
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En erreur"
#: build/lib/mailbox_manager/enums.py:24 build/lib/mailbox_manager/enums.py:34
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr "Désactivé"
#: build/lib/mailbox_manager/enums.py:25 mailbox_manager/enums.py:25
msgid "Action required"
msgstr "Action requise"
#: build/lib/mailbox_manager/models.py:33 mailbox_manager/models.py:33
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: build/lib/mailbox_manager/models.py:38 mailbox_manager/models.py:38
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: build/lib/mailbox_manager/models.py:43 mailbox_manager/models.py:43
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: build/lib/mailbox_manager/models.py:69 mailbox_manager/models.py:69
msgid "support email"
msgstr "adresse email du support"
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
msgid "last check details"
msgstr "détails de la dernière vérification"
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
msgid "expected config"
msgstr "configuration attendue"
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: build/lib/mailbox_manager/models.py:202 mailbox_manager/models.py:202
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:276 mailbox_manager/models.py:276
msgid "local_part"
msgstr "local_part"
#: build/lib/mailbox_manager/models.py:290 mailbox_manager/models.py:290
msgid "secondary email address"
msgstr "adresse email secondaire"
#: build/lib/mailbox_manager/models.py:301 mailbox_manager/models.py:301
msgid "email"
msgstr "email"
#: build/lib/mailbox_manager/models.py:307 mailbox_manager/models.py:307
msgid "Mailbox"
msgstr "Boîte mail"
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
msgid "Mailboxes"
msgstr "Boîtes mail"
#: build/lib/mailbox_manager/models.py:350 mailbox_manager/models.py:350
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: build/lib/mailbox_manager/models.py:407 mailbox_manager/models.py:407
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:420 mailbox_manager/models.py:420
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: build/lib/mailbox_manager/models.py:463 mailbox_manager/models.py:463
msgid "destination address"
msgstr "adresse de destination"
#: build/lib/mailbox_manager/models.py:474 mailbox_manager/models.py:474
msgid "Alias"
msgstr "Alias"
#: build/lib/mailbox_manager/models.py:475 mailbox_manager/models.py:475
msgid "Aliases"
msgstr "Alias"
#: build/lib/mailbox_manager/utils/dimail.py:296
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: build/lib/mailbox_manager/utils/dimail.py:307
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr "Anglais"
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr "Français"
#: core/templates/mail/html/maildomain_action_required.html:195
#: core/templates/mail/text/maildomain_action_required.txt:5
msgid "Some actions are required on your domain"
@@ -683,7 +429,7 @@ msgstr "Hourra !"
#: core/templates/mail/html/maildomain_enabled.html:206
#, python-format
msgid "Your domain <b>%(name)s</b> can be used now."
msgstr "Votre domaine <b>%(name)s</b> est prêt à être exploité."
msgstr "Votre domaine <b>%(name)s</b> est prêt à être utilisé."
#: core/templates/mail/html/maildomain_enabled.html:211
#: core/templates/mail/html/maildomain_invitation.html:211
@@ -892,3 +638,236 @@ msgstr "Le domaine %(name)s a rencontré une erreur. Tant que cette erreur persi
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
msgstr "Pour en savoir plus : Visitez le site de la Suite numérique [%(link)s] pour découvrir lensemble des outils proposés."
#: mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr "Importer les emails depuis dimail"
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
#: mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
#: mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr "Importer des alias depuis dimail"
#: mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr "Synchronisation réussie pour %(domain)s. %(count_imported)s alias importés : %(aliases)s"
#: mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
#: mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr "- %(domain)s avec le message : %(err)s"
#: mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
#: mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr "Domaines mis à jour : %(domains)s"
#: mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
#: mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
#: mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
#: mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
#: mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
#: mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr "Lecteur"
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr "Actif"
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En erreur"
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr "Désactivé"
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr "Action requise"
#: mailbox_manager/models.py:35
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: mailbox_manager/models.py:40
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: mailbox_manager/models.py:45
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: mailbox_manager/models.py:71
msgid "support email"
msgstr "adresse email du support"
#: mailbox_manager/models.py:75
msgid "last check details"
msgstr "détails de la dernière vérification"
#: mailbox_manager/models.py:76
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: mailbox_manager/models.py:81
msgid "expected config"
msgstr "configuration attendue"
#: mailbox_manager/models.py:82
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: mailbox_manager/models.py:87
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: mailbox_manager/models.py:88
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: mailbox_manager/models.py:204
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: mailbox_manager/models.py:205
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: mailbox_manager/models.py:278
msgid "local_part"
msgstr "local_part"
#: mailbox_manager/models.py:292
msgid "secondary email address"
msgstr "adresse email secondaire"
#: mailbox_manager/models.py:303
msgid "email"
msgstr "email"
#: mailbox_manager/models.py:309
msgid "Mailbox"
msgstr "Boîte mail"
#: mailbox_manager/models.py:310
msgid "Mailboxes"
msgstr "Boîtes mail"
#: mailbox_manager/models.py:352
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: mailbox_manager/models.py:409
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: mailbox_manager/models.py:410
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: mailbox_manager/models.py:439
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: mailbox_manager/models.py:483
msgid "destination address"
msgstr "adresse de destination"
#: mailbox_manager/models.py:501
msgid "Alias"
msgstr "Alias"
#: mailbox_manager/models.py:502
msgid "Aliases"
msgstr "Alias"
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: people/settings.py:159
msgid "English"
msgstr "Anglais"
#: people/settings.py:160
msgid "French"
msgstr "Français"
+5 -4
View File
@@ -75,15 +75,16 @@ def sync_aliases_from_dimail(modeladmin, request, queryset): # pylint: disable=
messages.success(
request,
_(
"Synchronisation succeed for %(domain)s. %(imported_aliases)\
imported aliases: %(mailboxes)s"
"Synchronisation succeed for %(domain)s.\
Imported %(count_imported)s aliases: %(aliases)s"
)
% {
"domain": domain.name,
"number_imported": len(imported_aliases),
"mailboxes": ", ".join(imported_aliases),
"count_imported": len(imported_aliases),
"aliases": ", ".join(imported_aliases),
},
)
if excluded_domains:
messages.warning(
request,
@@ -2,6 +2,7 @@
from django.db.models import Q, Subquery
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import exceptions, filters, mixins, status, viewsets
from rest_framework.decorators import action
@@ -9,6 +10,7 @@ from rest_framework.response import Response
from core import models as core_models
from core.api.client.serializers import UserSerializer
from core.exceptions import EmailAlreadyKnownException
from mailbox_manager import enums, models
from mailbox_manager.api import permissions
@@ -348,6 +350,7 @@ class MailDomainInvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to domain management.
@@ -362,9 +365,13 @@ class MailDomainInvitationViewset(
- issuer : User, automatically added from user making query, if allowed
- domain : Domain, automatically added from requested URI
Return a newly created invitation
or an access if email is already linked to an existing user
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/<version>/mail-domains/<domain_slug>/invitations/:<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
@@ -406,6 +413,30 @@ class MailDomainInvitationViewset(
return queryset
def create(self, request, *args, **kwargs):
"""Attempt to create invitation. If user is already registered,
they don't need an invitation but an access, which we create here."""
email = request.data["email"]
try:
return super().create(request, *args, **kwargs)
except EmailAlreadyKnownException as exc:
user = models.User.objects.get(email__iexact=email)
models.MailDomainAccess.objects.create(
user=user,
domain=models.MailDomain.objects.get(slug=kwargs["domain_slug"]),
role=request.data["role"],
)
raise exc
@action(detail=True, methods=["post"])
def refresh(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""Enable mailbox. Send a request to dimail and change status in our DB"""
invitation = get_object_or_404(models.MailDomainInvitation, id=kwargs["id"])
invitation.refresh()
invitation.email_invitation()
return Response(serializers.MailDomainInvitationSerializer(invitation).data)
class AliasViewSet(
mixins.CreateModelMixin,
@@ -26,7 +26,7 @@ class DomainPermission(IsAuthenticated):
slug=view.kwargs.get("domain_slug", ""),
accesses__user=request.user,
)
# domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
if request.method.lower() == "delete":
return abilities.get("manage_accesses", False)
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('local_part', models.CharField(max_length=100)),
('destination', models.EmailField(max_length=254, verbose_name='destination address')),
('destination', models.CharField(max_length=254, validators=[django.core.validators.EmailValidator(allowlist=['localhost', 'devnull'])], verbose_name='destination address')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='mailbox_manager.maildomain')),
],
options={
+35 -6
View File
@@ -9,6 +9,7 @@ from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.validators import EmailValidator
from django.db import models
from django.db.models.functions import Lower
from django.template.loader import render_to_string
@@ -16,6 +17,7 @@ from django.utils.text import slugify
from django.utils.translation import get_language, gettext, override
from django.utils.translation import gettext_lazy as _
from core.exceptions import EmailAlreadyKnownException
from core.models import BaseInvitation, BaseModel, Organization, User
from mailbox_manager.enums import (
@@ -109,7 +111,7 @@ class MailDomain(BaseModel):
if user.is_authenticated:
try:
role = self.accesses.filter(user=user).values("role")[0]["role"]
except (MailDomainAccess.DoesNotExist, IndexError):
except MailDomainAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [
@@ -222,7 +224,7 @@ class MailDomainAccess(BaseModel):
authenticated_user_role = user.mail_domain_accesses.get(
domain=self.domain
).role
except (MailDomainAccess.DoesNotExist, IndexError):
except MailDomainAccess.DoesNotExist, IndexError:
return []
# only an owner can set an owner role
@@ -250,7 +252,7 @@ class MailDomainAccess(BaseModel):
if user.is_authenticated:
try:
role = user.mail_domain_accesses.filter(domain=self.domain).get().role
except (MailDomainAccess.DoesNotExist, IndexError):
except MailDomainAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [
@@ -349,6 +351,8 @@ Last name and Domain already exists.",
raise exceptions.ValidationError(
_("You can't create or update a mailbox for a disabled domain.")
)
self.local_part = self.local_part.lower()
return super().save(*args, **kwargs)
@property
@@ -412,6 +416,23 @@ class MailDomainInvitation(BaseInvitation):
)
]
def refresh(self):
"""Create domain access if refresh fail because user was created
after invitation expiration"""
try:
super().refresh()
except EmailAlreadyKnownException as exc:
user = User.objects.get(email__iexact=self.email)
MailDomainAccess.objects.create(
user=user,
domain=self.domain,
role=self.role,
)
self.delete() # delete related invitation
raise exc
def __str__(self):
return f"{self.email} invited to {self.domain}"
@@ -440,7 +461,7 @@ class MailDomainInvitation(BaseInvitation):
role = self.domain.accesses.filter(user=user).values("role")[0][
"role"
]
except (self._meta.model.DoesNotExist, IndexError):
except self._meta.model.DoesNotExist, IndexError:
role = None
can_delete = role in [
@@ -460,7 +481,15 @@ class Alias(BaseModel):
"""Model for aliases."""
local_part = models.CharField(max_length=100, blank=False)
destination = models.EmailField(_("destination address"), null=False, blank=False)
destination = models.CharField(
_("destination address"),
max_length=254,
null=False,
blank=False,
validators=[
EmailValidator(allowlist=["localhost", "devnull"]),
],
)
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
@@ -488,7 +517,7 @@ class Alias(BaseModel):
edit aliases, but also viewer if the alias points to their email."""
try:
role = user.mail_domain_accesses.get(domain=self.domain).role
except (MailDomainAccess.DoesNotExist, IndexError):
except MailDomainAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [
@@ -182,3 +182,33 @@ def test_api_aliases_create__existing_mailbox_ok(dimail_token_ok):
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__devnull_destination_ok(dimail_token_ok):
"""Can create alias where destination is devnull@devnull."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "spammy-address",
"domain": access.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "spammy-address", "destination": "devnull@devnull"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@@ -11,8 +11,7 @@ from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
from mailbox_manager.api.client import serializers
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
@@ -20,13 +19,12 @@ pytestmark = pytest.mark.django_db
def test_api_domain_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
domain = factories.MailDomainEnabledFactory()
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
response = APIClient().post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
{
"email": "some.email@domain.com",
"role": "admin",
},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -40,16 +38,15 @@ def test_api_domain_invitations__create__no_access_forbidden_not_found():
for a domain they don't manage."""
user = core_factories.UserFactory()
domain = factories.MailDomainEnabledFactory()
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
{
"email": "some.email@domain.com",
"role": "viewer",
},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -65,24 +62,21 @@ def test_api_domain_invitations__admin_should_create_invites(role):
domain = factories.MailDomainEnabledFactory()
factories.MailDomainAccessFactory(domain=domain, user=user, role=role)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
{
"email": "some.email@domain.com",
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [invitation_values["email"]]
assert email.to == ["some.email@domain.com"]
assert email.subject == "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
@@ -96,16 +90,14 @@ def test_api_domain_invitations__no_access_forbidden_not_found():
domain=domain, user=user, role=enums.MailDomainRoleChoices.VIEWER
)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
{
"email": "some.email@domain.com",
"role": "viewer",
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -125,42 +117,81 @@ def test_api_domain_invitations__should_not_create_duplicate_invitations():
domain=domain, user=user, role=enums.MailDomainRoleChoices.OWNER
)
# Create a new invitation to the same domain with the exact same email address
duplicated_invitation = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build(email=existing_invitation.email)
).data
# New invitation to the same domain with the exact same email address
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
duplicated_invitation,
{
"email": existing_invitation.email,
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Mail domain invitation with this Email address and Domain already exists."
]
assert models.MailDomainInvitation.objects.count() == 1 # and specifically, not 2
def test_api_domain_invitations__should_not_invite_when_user_already_exists():
def test_api_domain_invitations__inviting_known_email_should_create_access():
"""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,
{
"email": existing_user.email,
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
assert not models.MailDomainInvitation.objects.exists()
assert models.MailDomainAccess.objects.filter(user=existing_user).exists()
def test_api_domain_invitations__inviting_known_email_case_insensitive():
"""Email matching should be case-insensitive when creating access for existing users."""
# Create a user with lowercase email
existing_user = core_factories.UserFactory(email="john.doe@example.com")
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
# Try to invite with same email different case - should create access for existing user
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
{
"email": "John.Doe@Example.COM",
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
# No invitation should be created
assert not models.MailDomainInvitation.objects.exists()
# Access should be created for the existing user (not a new user)
assert models.MailDomainAccess.objects.filter(user=existing_user).exists()
assert models.MailDomainAccess.objects.filter(
user=existing_user, domain=access.domain
).exists()
# Ensure only one user exists (no duplicate user created)
assert models.User.objects.filter(email__iexact="john.doe@example.com").count() == 1
@@ -0,0 +1,76 @@
"""
Tests for MailDomainInvitation API endpoint in People's app mailbox_manager.
Focus on "delete" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__delete__anonymous():
"""Anonymous users should not be able to delete invitations."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__no_access_not_found():
"""Users should not be permitted to delete invitations
on domains they don't manage."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
other_access = factories.MailDomainAccessFactory(role="owner") # unrelated access
client = APIClient()
client.force_login(other_access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__viewers_forbidden():
"""Domain viewers should not be permitted to delete invitations."""
access = factories.MailDomainAccessFactory(role="viewer")
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainInvitation.objects.count() == 1
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_domain_invitations__delete_admins_ok(role):
"""Domain owners and admins should be able to delete invitations."""
access = factories.MailDomainAccessFactory(role=role)
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.MailDomainInvitation.objects.exists()
@@ -0,0 +1,112 @@
"""
Tests for MailDomainInvitations API endpoint in People's app mailbox_manager.
Focus on "refresh" action.
"""
import pytest
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__anonymous_cannot_refresh():
"""Anonymous are not allowed to refresh."""
invitation = factories.MailDomainInvitationFactory()
response = APIClient().post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__no_access_cannot_see_invitation():
"""Users with no permission on the domain should be returned a 404 not found."""
invitation = factories.MailDomainInvitationFactory()
otro_access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.ADMIN
)
client = APIClient()
client.force_login(otro_access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_domain_invitations__viewer_cannot_refresh():
"""Viewers cannot refresh invitations."""
invitation = factories.MailDomainInvitationFactory()
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_domain_invitations__admins_can_refresh_expired_invitations():
"""Admins can refresh invitations."""
with freeze_time("2025-12-16"):
invitation = factories.MailDomainInvitationFactory()
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER
)
assert invitation.is_expired is True # check invitation is correctly expired
client = APIClient()
client.force_login(access.user)
# can refresh expired invitations
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_expired"] is False
assert models.MailDomainInvitation.objects.count() == 1
invitation.refresh_from_db()
# can also refresh valid
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_expired"] is False
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__refreshing_invitation_can_convert_to_access():
"""In the event of someone creating their account after their invitation expired,
said invitation has not been converted to access.
We don't want to create a new invitation but create an access instead."""
with freeze_time("2025-12-16"):
invitation = factories.MailDomainInvitationFactory()
core_factories.UserFactory(email=invitation.email)
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
assert models.MailDomainAccess.objects.count() == 2
assert not models.MailDomainInvitation.objects.exists()
@@ -58,7 +58,7 @@ def test_api_mailboxes__create_no_access_forbidden_not_found(mailbox_data):
assert not models.Mailbox.objects.exists()
def test_api_mailboxes__create_viewer_failure(mailbox_data):
def test_api_mailboxes__create_viewer_forbidden(mailbox_data):
"""Users with viewer role should not be able to create mailbox on the mail domain."""
mail_domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(
@@ -89,7 +89,7 @@ def test_api_mailboxes__create_display_name_must_be_unique():
new_mailbox_data = {
"local_part": "something_else",
"first_name": existing_mailbox.first_name.upper(), # ensure case-insensitivity
"first_name": existing_mailbox.first_name.upper(), # checks case-insensitivity
"last_name": existing_mailbox.last_name.lower(),
}
response = client.post(
@@ -239,6 +239,35 @@ def test_api_mailboxes__create_with_accent_success(role, dimail_token_ok):
}
@responses.activate
def test_api_mailboxes__create_lowercase(dimail_token_ok, mailbox_data):
"""Should lowercase emails on creation."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.ADMIN)
mailbox_data["local_part"] = "WeiRdCaSe"
# ensure response
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["local_part"] == mailbox_data["local_part"].lower()
def test_api_mailboxes__create_administrator_missing_fields():
"""
Administrator users should not be able to create mailboxes
@@ -113,7 +113,7 @@ Please add a valid secondary email before trying again."
],
)
@responses.activate
def test_api_mailboxes__reset_password_admin_successful(role):
def test_api_mailboxes__reset_password_admin_successful(role, dimail_token_ok): # pylint: disable=W0613
"""Owner and admin users should be able to reset password on mailboxes.
New password should be sent to secondary email."""
mail_domain = factories.MailDomainEnabledFactory()
@@ -124,12 +124,7 @@ def test_api_mailboxes__reset_password_admin_successful(role):
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
# token response in fixtures
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
@@ -161,7 +156,7 @@ def test_api_mailboxes__reset_password_non_existing():
@responses.activate
def test_api_mailboxes__reset_password_connexion_failed():
def test_api_mailboxes__reset_password_connexion_failed(dimail_token_ok): # pylint: disable=W0613
"""
No mail is sent when password reset failed because of connexion error.
"""
@@ -175,12 +170,7 @@ def test_api_mailboxes__reset_password_connexion_failed():
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
# token response in fixtures
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
@@ -9,17 +9,15 @@ import responses
from rest_framework import status
from mailbox_manager import factories
from mailbox_manager.tests.fixtures.dimail import TOKEN_OK
## DIMAIL RESPONSES
@pytest.fixture(name="dimail_token_ok")
def fixture_dimail_token_ok():
"""Mock dimail response when /token/ endpoit is given valid credentials."""
responses.add(
responses.GET,
responses.get(
re.compile(r".*/token/"),
body=TOKEN_OK,
json={"access_token": "token", "token_type": "bearer"},
status=status.HTTP_200_OK,
content_type="application/json",
)
+1 -5
View File
@@ -287,10 +287,6 @@ DOMAIN_SPEC = [
]
## TOKEN
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
## ALLOWS
def response_allows_created(user_name, domain_name):
"""mimic dimail response upon successful allows creation.
@@ -301,4 +297,4 @@ def response_allows_created(user_name, domain_name):
## MAILBOXES
def response_mailbox_created(email_address):
"""mimic dimail response upon successful mailbox creation."""
return json.dumps({"email": email_address, "password": "password"})
return json.dumps({"email": email_address.lower(), "password": "password"})
@@ -0,0 +1,19 @@
"""
Unit tests for the Alias model
"""
import pytest
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_models_aliases__devnull_destination_ok():
"""Can create alias where destination is devnull@devnull."""
models.Alias.objects.create(
local_part="spam",
domain=factories.MailDomainEnabledFactory(),
destination="devnull@devnull",
)
@@ -19,7 +19,6 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
TOKEN_OK,
response_mailbox_created,
)
@@ -101,7 +100,9 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(
client, dimail_token_ok
): # pylint: disable=W0613
"""Test admin action should switch domain state to ENABLED
when dimail's response is "ok". It should also activate any pending mailbox."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
@@ -127,13 +128,7 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
content_type="application/json",
)
# we need to get a token to create mailboxes
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
@@ -208,7 +203,7 @@ def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(clie
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes(client):
def test_send_pending_mailboxes(client, dimail_token_ok): # pylint: disable=W0613
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -223,13 +218,7 @@ def test_send_pending_mailboxes(client):
url = reverse("admin:mailbox_manager_maildomain_changelist")
for mailbox in mailboxes:
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
@@ -247,7 +236,7 @@ def test_send_pending_mailboxes(client):
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes__listing_failed_mailboxes(client):
def test_send_pending_mailboxes__listing_failed_mailboxes(client, dimail_token_ok): # pylint: disable=W0613
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -261,13 +250,7 @@ def test_send_pending_mailboxes__listing_failed_mailboxes(client):
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
@@ -199,6 +199,12 @@ def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint
"destination": existing_mailbox.secondary_email,
"allow_to_send": False,
},
{ # alias to devnull@devnull
"username": "spam",
"domain": alias.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
]
responses.get(
re.compile(rf".*/domains/{alias.domain.name}/aliases/"),
@@ -209,8 +215,8 @@ def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint
imported_aliases = dimail_client.import_aliases(alias.domain)
assert len(imported_aliases) == 3
assert models.Alias.objects.count() == 4
assert len(imported_aliases) == 4
assert models.Alias.objects.count() == 5
@pytest.mark.parametrize(
+2 -4
View File
@@ -173,9 +173,6 @@ class Base(Configuration):
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"),
os.path.join(
BASE_DIR, "admin", "templates"
), # enforce load before Django's admin
],
"OPTIONS": {
"context_processors": [
@@ -227,7 +224,6 @@ class Base(Configuration):
INSTALLED_APPS = [
# People
"admin.apps.PeopleAdminConfig", # replaces 'django.contrib.admin'
"core",
"demo",
"mailbox_manager.apps.MailboxManagerConfig",
@@ -246,6 +242,7 @@ class Base(Configuration):
"parler",
"rest_framework",
"treebeard",
"lasuite.admin", # must be before django.contrib.admin
# Django
"django.contrib.auth",
"django.contrib.contenttypes",
@@ -254,6 +251,7 @@ class Base(Configuration):
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.admin",
# OIDC third party
"mozilla_django_oidc",
]
+25 -25
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.22.0"
version = "1.23.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,51 +17,51 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.13",
]
description = "An application to handle contacts and teams."
keywords = ["Django", "Contacts", "Teams", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.11"
requires-python = "~=3.14.2"
dependencies = [
"Brotli==1.2.0",
"PyJWT==2.10.1",
"boto3==1.42.24",
"PyJWT==2.12.1",
"boto3==1.42.68",
"celery[redis]==5.6.2",
"django-celery-beat==2.8.1",
"django-celery-beat==2.9.0",
"django-celery-results==2.6.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-extensions==4.1",
"django-lasuite==0.0.22",
"django-lasuite==0.0.25",
"django-oauth-toolkit==3.2.0",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages==1.14.6",
"django-timezone-field>=5.1",
"django-treebeard==4.8.0",
"django-zxcvbn-password-validator==1.5.2",
"django==5.2.9",
"django-treebeard==5.0.5",
"django-zxcvbn-password-validator==1.5.3",
"django==6.0.3",
"djangorestframework==3.16.1",
"dockerflow==2024.4.2",
"dockerflow==2026.3.4",
"drf_spectacular==0.29.0",
"drf_spectacular[sidecar]==0.29.0",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"flower==2.0.1",
"gunicorn==23.0.0",
"gunicorn==25.1.0",
"jaraco.context>=6.1.0",
"joserfc==1.6.1",
"joserfc==1.6.3",
"jsonschema==4.26.0",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"redis<=7.1.0",
"psycopg[binary]==3.3.3",
"redis<=7.3.0",
"requests==2.32.5",
"sentry-sdk[django]==2.49.0",
"whitenoise==6.11.0",
"sentry-sdk[django]==2.54.0",
"whitenoise==6.12.0",
]
[project.urls]
@@ -72,20 +72,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"drf-spectacular-sidecar==2026.1.1",
"drf-spectacular-sidecar==2026.3.1",
"ipdb==0.13.13",
"ipython==9.9.0",
"jq==1.10.0",
"pyfakefs==6.0.0",
"ipython==9.11.0",
"jq==1.11.0",
"pyfakefs==6.1.5",
"pylint-django==2.7.0",
"pylint==4.0.4",
"pylint==4.0.5",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.11",
"responses==0.26.0",
"ruff==0.15.6",
"types-requests==2.32.4.20260107",
"freezegun==1.5.5",
]
+1803
View File
File diff suppressed because it is too large Load Diff
+862 -3
View File
@@ -1,5 +1,864 @@
import { cunninghamConfig } from '@gouvfr-lasuite/ui-kit';
import { getThemesFromGlobals } from '@gouvfr-lasuite/cunningham-tokens';
export default {
...cunninghamConfig,
export const commonTokenOverrides = {
components: {
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
modal: {
'width-small': '342px',
},
tooltip: {
padding: '4px 8px',
},
button: {
'medium-height': '40px',
'medium-text-height': '40px',
'border-radius': '4px',
'border-radius--active': '4px',
'border-radius--focus': '4px',
},
datagrid: {
'header--color': 'ref(contextuals.content.semantic.neutral.primary)',
'header--size': '12px',
'header--weight': '500',
'body--background-color-hover':
'ref(contextuals.background.semantic.neutral.tertiary)',
},
'forms-checkbox': {
'font-size': 'ref(globals.font.sizes.sm)',
},
badge: {
'font-size': 'ref(globals.font.sizes.xs)',
'border-radius': '12px',
'padding-inline': 'ref(globals.spacings.xs)',
'padding-block': 'ref(globals.spacings.2xs)',
accent: {
'background-color':
'ref(contextuals.background.semantic.brand.secondary)',
color: 'ref(contextuals.content.semantic.brand.secondary)',
},
neutral: {
'background-color':
'ref(contextuals.background.semantic.neutral.secondary)',
color: 'ref(contextuals.content.semantic.neutral.secondary)',
},
danger: {
'background-color':
'ref(contextuals.background.semantic.error.secondary)',
color: 'ref(contextuals.content.semantic.error.secondary)',
},
success: {
'background-color':
'ref(contextuals.background.semantic.success.secondary)',
color: 'ref(contextuals.content.semantic.success.secondary)',
},
warning: {
'background-color':
'ref(contextuals.background.semantic.warning.secondary)',
color: 'ref(contextuals.content.semantic.warning.secondary)',
},
info: {
'background-color':
'ref(contextuals.background.semantic.info.secondary)',
color: 'ref(contextuals.content.semantic.info.secondary)',
},
},
},
};
export const commonGlobals = {
components: {
'la-gaufre': false,
'home-proconnect': false,
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
'xl-alt': '5rem',
'lg-alt': '4.5rem',
'md-alt': '4rem',
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: {
thin: 100,
extrabold: 800,
black: 900,
},
families: {
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
spacings: {
'0': '0',
none: '0',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
'2xl': '3rem',
xxxl: '3.5rem',
'3xl': '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
xs: '480px',
mobile: '768px',
tablet: '1024px',
},
};
export const whiteLabelGlobals = {
colors: {
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
'brand-200': '#BEC5F0',
'brand-250': '#AFB5F1',
'brand-300': '#A0A5F6',
'brand-350': '#8F94FD',
'brand-400': '#8184FC',
'brand-450': '#7576EE',
'brand-500': '#6969DF',
'brand-550': '#5E5CD0',
'brand-600': '#534FC2',
'brand-650': '#4844AD',
'brand-700': '#3E3B98',
'brand-750': '#36347D',
'brand-800': '#2D2F5F',
'brand-850': '#262848',
'brand-900': '#1C1E32',
'brand-950': '#11131F',
'gray-000': '#FFFFFF',
'gray-025': '#F8F8F9',
'gray-050': '#F0F0F3',
'gray-100': '#E2E2EA',
'gray-150': '#D3D4E0',
'gray-200': '#C5C6D5',
'gray-250': '#B7B7CB',
'gray-300': '#A9A9BF',
'gray-350': '#9C9CB2',
'gray-400': '#8F8FA4',
'gray-450': '#828297',
'gray-500': '#75758A',
'gray-550': '#69697D',
'gray-600': '#5D5D70',
'gray-650': '#515164',
'gray-700': '#454558',
'gray-750': '#3A3A4C',
'gray-800': '#2F303D',
'gray-850': '#25252F',
'gray-900': '#1B1B23',
'gray-950': '#111114',
'gray-1000': '#000000',
'info-050': '#EAF2F9',
'info-100': '#D5E4F3',
'info-150': '#BFD7F0',
'info-200': '#A7CAEE',
'info-250': '#8DBDEF',
'info-300': '#6EB0F2',
'info-350': '#50A2F5',
'info-400': '#3593F4',
'info-450': '#1185ED',
'info-500': '#0077DE',
'info-550': '#0069CF',
'info-600': '#005BC0',
'info-650': '#0D4EAA',
'info-700': '#124394',
'info-750': '#163878',
'info-800': '#192F5A',
'info-850': '#192541',
'info-900': '#141B2D',
'info-950': '#0C111C',
'success-050': '#E8F1EA',
'success-100': '#CFE4D4',
'success-150': '#BAD9C1',
'success-200': '#A2CFAD',
'success-250': '#86C597',
'success-300': '#6CBA83',
'success-350': '#4FB070',
'success-400': '#40A363',
'success-450': '#309556',
'success-500': '#1E884A',
'success-550': '#027B3E',
'success-600': '#016D31',
'success-650': '#006024',
'success-700': '#005317',
'success-750': '#0D4511',
'success-800': '#11380E',
'success-850': '#132A11',
'success-900': '#101E0F',
'success-950': '#091209',
'warning-050': '#F8F0E9',
'warning-100': '#F1E0D3',
'warning-150': '#ECD0BC',
'warning-200': '#E8C0A4',
'warning-250': '#E8AE8A',
'warning-300': '#EB9970',
'warning-350': '#E98456',
'warning-400': '#E57036',
'warning-450': '#DA5E18',
'warning-500': '#CB5000',
'warning-550': '#BC4200',
'warning-600': '#AD3300',
'warning-650': '#9E2300',
'warning-700': '#882011',
'warning-750': '#731E16',
'warning-800': '#58201A',
'warning-850': '#401D18',
'warning-900': '#2E1714',
'warning-950': '#1D0F0D',
'error-050': '#F9EFEC',
'error-100': '#F4DFD9',
'error-150': '#F0CEC6',
'error-200': '#EEBCB2',
'error-250': '#EEA99D',
'error-300': '#EF9486',
'error-350': '#F37C6E',
'error-400': '#F65F53',
'error-450': '#F0463D',
'error-500': '#E82322',
'error-550': '#D7010E',
'error-600': '#C00100',
'error-650': '#AA0000',
'error-700': '#910C06',
'error-750': '#731E16',
'error-800': '#58201A',
'error-850': '#401D18',
'error-900': '#2E1714',
'error-950': '#1D0F0D',
'red-050': '#FAEFEE',
'red-100': '#F4DEDD',
'red-150': '#F1CDCB',
'red-200': '#EFBBBA',
'red-250': '#EEA8A8',
'red-300': '#F09394',
'red-350': '#F37B7E',
'red-400': '#EF6569',
'red-450': '#E94A55',
'red-500': '#DA3B49',
'red-550': '#CA2A3C',
'red-600': '#BB1330',
'red-650': '#A90021',
'red-700': '#910A13',
'red-750': '#731E16',
'red-800': '#58201A',
'red-850': '#411D18',
'red-900': '#2E1714',
'red-950': '#1D0F0D',
'orange-050': '#F8F0E9',
'orange-100': '#F1E0D3',
'orange-150': '#ECD0BD',
'orange-200': '#EABFA6',
'orange-250': '#EBAC90',
'orange-300': '#EC9772',
'orange-350': '#E5845A',
'orange-400': '#D6774D',
'orange-450': '#C86A40',
'orange-500': '#B95D33',
'orange-550': '#AB5025',
'orange-600': '#9D4315',
'orange-650': '#8F3600',
'orange-700': '#812900',
'orange-750': '#6C2511',
'orange-800': '#572017',
'orange-850': '#401D18',
'orange-900': '#2E1714',
'orange-950': '#1D0F0D',
'brown-050': '#F6F0E8',
'brown-100': '#F1E0D3',
'brown-150': '#EBD0BA',
'brown-200': '#E2C0A6',
'brown-250': '#D4B398',
'brown-300': '#C6A58B',
'brown-350': '#B8987E',
'brown-400': '#AA8B71',
'brown-450': '#9D7E65',
'brown-500': '#8F7158',
'brown-550': '#82654C',
'brown-600': '#765841',
'brown-650': '#694C35',
'brown-700': '#5D412A',
'brown-750': '#51361E',
'brown-800': '#452A13',
'brown-850': '#392008',
'brown-900': '#29180A',
'brown-950': '#1B0F08',
'yellow-050': '#F3F0E7',
'yellow-100': '#E9E2CF',
'yellow-150': '#E1D4B7',
'yellow-200': '#D9C599',
'yellow-250': '#D2B677',
'yellow-300': '#CAA756',
'yellow-350': '#C2972E',
'yellow-400': '#B98900',
'yellow-450': '#AB7B00',
'yellow-500': '#9D6E00',
'yellow-550': '#916100',
'yellow-600': '#855400',
'yellow-650': '#784700',
'yellow-700': '#6C3A00',
'yellow-750': '#5F2E00',
'yellow-800': '#512302',
'yellow-850': '#3E1D10',
'yellow-900': '#2D1711',
'yellow-950': '#1D0F0D',
'green-050': '#E6F1E9',
'green-100': '#CFE4D5',
'green-150': '#B8D8C1',
'green-200': '#A0CFAE',
'green-250': '#84C59A',
'green-300': '#65BA86',
'green-350': '#45B173',
'green-400': '#23A562',
'green-450': '#029755',
'green-500': '#008948',
'green-550': '#017B3B',
'green-600': '#006E2E',
'green-650': '#006022',
'green-700': '#005314',
'green-750': '#0D4510',
'green-800': '#11380E',
'green-850': '#132A11',
'green-900': '#101E0F',
'green-950': '#091209',
'blue-1-050': '#EBF1F9',
'blue-1-100': '#D6E4F4',
'blue-1-150': '#C1D7F0',
'blue-1-200': '#AACAEF',
'blue-1-250': '#8FBCEF',
'blue-1-300': '#7CAFEB',
'blue-1-350': '#68A1E4',
'blue-1-400': '#5B94D6',
'blue-1-450': '#4E86C7',
'blue-1-500': '#4279B9',
'blue-1-550': '#356CAC',
'blue-1-600': '#28609E',
'blue-1-650': '#1B5390',
'blue-1-700': '#0B4783',
'blue-1-750': '#0F3C6E',
'blue-1-800': '#133059',
'blue-1-850': '#152641',
'blue-1-900': '#121C2D',
'blue-1-950': '#0B111C',
'blue-2-050': '#E7F3F4',
'blue-2-100': '#CEE7E9',
'blue-2-150': '#B2DCE0',
'blue-2-200': '#91D1D7',
'blue-2-250': '#68C7D0',
'blue-2-300': '#43BBC5',
'blue-2-350': '#00AFBA',
'blue-2-400': '#01A0AA',
'blue-2-450': '#00929D',
'blue-2-500': '#00848F',
'blue-2-550': '#007682',
'blue-2-600': '#016874',
'blue-2-650': '#005B67',
'blue-2-700': '#004E5A',
'blue-2-750': '#00424E',
'blue-2-800': '#003642',
'blue-2-850': '#002A38',
'blue-2-900': '#061E28',
'blue-2-950': '#071219',
'purple-050': '#F7F0F6',
'purple-100': '#EEE0EE',
'purple-150': '#E7D1E7',
'purple-200': '#DBBFE4',
'purple-250': '#D3AEE2',
'purple-300': '#CB99E1',
'purple-350': '#C188D9',
'purple-400': '#B47BCB',
'purple-450': '#A66EBD',
'purple-500': '#9961AF',
'purple-550': '#8B55A1',
'purple-600': '#7E4894',
'purple-650': '#723C87',
'purple-700': '#633376',
'purple-750': '#552A65',
'purple-800': '#452551',
'purple-850': '#35213D',
'purple-900': '#261A2C',
'purple-950': '#17111C',
'pink-050': '#F8EFF4',
'pink-100': '#F0DFEA',
'pink-150': '#EACEDF',
'pink-200': '#E9BBD1',
'pink-250': '#E9A7C2',
'pink-300': '#E095B4',
'pink-350': '#D685A8',
'pink-400': '#C7799B',
'pink-450': '#B86C8D',
'pink-500': '#AA5F80',
'pink-550': '#9C5374',
'pink-600': '#8E4767',
'pink-650': '#813B5B',
'pink-700': '#732E4F',
'pink-750': '#632643',
'pink-800': '#521F38',
'pink-850': '#3E1C2B',
'pink-900': '#2D171F',
'pink-950': '#1C0E12',
'black-000': '#1B1B2300',
'black-050': '#1B1B230D',
'black-100': '#1B1B231A',
'black-150': '#1B1B2326',
'black-200': '#1B1B2333',
'black-250': '#1B1B2340',
'black-300': '#1B1B234D',
'black-350': '#1B1B2359',
'black-400': '#1B1B2366',
'black-450': '#1B1B2373',
'black-500': '#1B1B2380',
'black-550': '#1B1B238C',
'black-600': '#1B1B2399',
'black-650': '#1B1B23A6',
'black-700': '#1B1B23B2',
'black-750': '#1B1B23BF',
'black-800': '#1B1B23CC',
'black-850': '#1B1B23D9',
'black-900': '#1B1B23E5',
'black-950': '#111114F2',
'white-000': '#F8F8F900',
'white-050': '#F8F8F90D',
'white-100': '#F8F8F91A',
'white-150': '#F8F8F926',
'white-200': '#F8F8F933',
'white-250': '#F8F8F940',
'white-300': '#F8F8F94D',
'white-350': '#F8F8F959',
'white-400': '#F8F8F966',
'white-450': '#F8F8F973',
'white-500': '#F8F8F980',
'white-550': '#F8F8F98C',
'white-600': '#F8F8F999',
'white-650': '#F8F8F9A6',
'white-700': '#F8F8F9B2',
'white-750': '#F8F8F9BF',
'white-800': '#F8F8F9CC',
'white-850': '#F8F8F9D9',
'white-900': '#F8F8F9E5',
'white-950': '#F8F8F9F2',
'white-975': '#F8F8F9F9',
},
...commonGlobals,
font: {
...commonGlobals.font,
families: {
base: 'Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif',
accent: 'Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif',
},
},
};
export const dsfrGlobals = {
...commonGlobals,
components: {
...commonGlobals.components,
'la-gaufre': true,
'home-proconnect': true,
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
},
colors: {
'logo-1': '#2845C1',
'logo-2': '#C83F49',
'brand-050': '#EDF0FF',
'brand-100': '#DAE2FF',
'brand-150': '#C8D3FF',
'brand-200': '#B5C4FF',
'brand-250': '#A2B6FF',
'brand-300': '#90A7FF',
'brand-350': '#7E98FF',
'brand-400': '#6C89FE',
'brand-450': '#5C7AF7',
'brand-500': '#4C6CEF',
'brand-550': '#3E5DE7',
'brand-600': '#304DDF',
'brand-650': '#2845C1',
'brand-700': '#223E9E',
'brand-750': '#1F367D',
'brand-800': '#1B2E5F',
'brand-850': '#172446',
'brand-900': '#121B30',
'brand-950': '#0C111A',
'gray-100': '#DFE2EA',
'gray-150': '#CFD5DE',
'gray-200': '#C1C7D3',
'gray-250': '#B2B9C7',
'gray-300': '#A4ABBC',
'gray-350': '#969EB0',
'gray-400': '#8891A4',
'gray-450': '#7B8498',
'gray-500': '#6D778C',
'gray-550': '#626A80',
'gray-600': '#555E74',
'gray-650': '#4A5267',
'gray-700': '#3F4759',
'gray-750': '#363B4C',
'gray-800': '#2B303D',
'gray-850': '#222631',
'gray-900': '#181B24',
'gray-950': '#0F1117',
'gray-1000': '#000000',
'info-050': '#E7F2FF',
'info-100': '#CFE5FF',
'info-150': '#B7D7FF',
'info-200': '#A0CAFE',
'info-250': '#8CBCF9',
'info-300': '#77AEF4',
'info-350': '#63A0EE',
'info-400': '#5092E7',
'info-450': '#4185DC',
'info-500': '#3677CC',
'info-550': '#2F6ABB',
'info-600': '#265EAA',
'info-650': '#28528F',
'info-700': '#274775',
'info-750': '#243C5E',
'info-800': '#20314A',
'info-850': '#1B2637',
'info-900': '#141C27',
'info-950': '#0D1118',
'success-050': '#DEF7E6',
'success-100': '#BAEECF',
'success-150': '#A5E2C0',
'success-200': '#95D4B3',
'success-250': '#85C6A7',
'success-300': '#74B99B',
'success-350': '#65AB8F',
'success-400': '#579E84',
'success-450': '#4B9079',
'success-500': '#40836F',
'success-550': '#367664',
'success-600': '#2B695A',
'success-650': '#2C5A50',
'success-700': '#2A4D45',
'success-750': '#26403C',
'success-800': '#213430',
'success-850': '#1B2826',
'success-900': '#151D1C',
'success-950': '#0D1212',
'warning-050': '#FFEEDF',
'warning-100': '#FFDCBE',
'warning-150': '#FFCA9C',
'warning-200': '#FFB778',
'warning-250': '#FDA54F',
'warning-300': '#F59425',
'warning-350': '#E78613',
'warning-400': '#D7790C',
'warning-450': '#C86C08',
'warning-500': '#B85F03',
'warning-550': '#A75400',
'warning-600': '#984800',
'warning-650': '#814112',
'warning-700': '#6C3A19',
'warning-750': '#58321C',
'warning-800': '#452A1A',
'warning-850': '#352117',
'warning-900': '#261813',
'warning-950': '#170F0C',
'error-050': '#FFEDEB',
'error-100': '#FFDAD7',
'error-150': '#FFC7C2',
'error-200': '#FFB3AD',
'error-250': '#FF9F99',
'error-300': '#FF8984',
'error-350': '#FF706E',
'error-400': '#FB5759',
'error-450': '#F63A45',
'error-500': '#E32C39',
'error-550': '#CF202D',
'error-600': '#BD0F23',
'error-650': '#9D2227',
'error-700': '#812727',
'error-750': '#672624',
'error-800': '#512220',
'error-850': '#3D1C1B',
'error-900': '#2A1614',
'error-950': '#190E0D',
'red-050': '#FFEDEB',
'red-100': '#FFDAD7',
'red-150': '#FFC7C2',
'red-200': '#FFB3AD',
'red-250': '#FF9F99',
'red-300': '#FF8984',
'red-350': '#FF706E',
'red-400': '#FB5759',
'red-450': '#F63A45',
'red-500': '#E32C39',
'red-550': '#CF202D',
'red-600': '#BD0F23',
'red-650': '#9D2227',
'red-700': '#812727',
'red-750': '#672624',
'red-800': '#512220',
'red-850': '#3D1C1B',
'red-900': '#410003',
'red-950': '#190E0D',
'orange-050': '#FCEDEB',
'orange-100': '#F8DCD7',
'orange-150': '#F1CCC5',
'orange-200': '#EABCB4',
'orange-250': '#E2ACA2',
'orange-300': '#DA9C92',
'orange-350': '#D28C81',
'orange-400': '#CA7C70',
'orange-450': '#BE6E62',
'orange-500': '#AE6257',
'orange-550': '#9E564D',
'orange-600': '#8F4B42',
'orange-650': '#79443D',
'orange-700': '#643C37',
'orange-750': '#513430',
'orange-800': '#412B28',
'orange-850': '#312220',
'orange-900': '#231918',
'orange-950': '#150F0F',
'brown-050': '#F9EFEA',
'brown-100': '#F3DFD3',
'brown-150': '#EACFC1',
'brown-200': '#E2BFAE',
'brown-250': '#D8B19C',
'brown-300': '#D0A189',
'brown-350': '#C3937B',
'brown-400': '#B5866D',
'brown-450': '#A77A62',
'brown-500': '#996D57',
'brown-550': '#8B614D',
'brown-600': '#7C5542',
'brown-650': '#6A4C3C',
'brown-700': '#594236',
'brown-750': '#49382F',
'brown-800': '#3B2E28',
'brown-850': '#2D2420',
'brown-900': '#201A18',
'brown-950': '#13100F',
'yellow-050': '#FDF1C5',
'yellow-100': '#FBE18E',
'yellow-150': '#F4D261',
'yellow-200': '#EAC244',
'yellow-250': '#DFB41B',
'yellow-300': '#D1A516',
'yellow-350': '#C49711',
'yellow-400': '#B78A0C',
'yellow-450': '#A87D07',
'yellow-500': '#9B6F02',
'yellow-550': '#8D6300',
'yellow-600': '#7F5600',
'yellow-650': '#6E4C11',
'yellow-700': '#5D4219',
'yellow-750': '#4D371B',
'yellow-800': '#3D2E1A',
'yellow-850': '#2F2417',
'yellow-900': '#221A12',
'yellow-950': '#14100C',
'green-050': '#E7F9B3',
'green-100': '#D5EC98',
'green-150': '#C5DE86',
'green-200': '#B5D174',
'green-250': '#A5C464',
'green-300': '#95B755',
'green-350': '#85AA45',
'green-400': '#769D39',
'green-450': '#688F30',
'green-500': '#5A8228',
'green-550': '#4D7621',
'green-600': '#416919',
'green-650': '#3A5B20',
'green-700': '#324E22',
'green-750': '#2C4122',
'green-800': '#24351D',
'green-850': '#1D2919',
'green-900': '#161E13',
'green-950': '#0E120C',
'blue-1-050': '#E7F2FF',
'blue-1-100': '#CFE5FF',
'blue-1-150': '#B7D7FF',
'blue-1-200': '#A0CAFE',
'blue-1-250': '#8CBCF9',
'blue-1-300': '#77AEF4',
'blue-1-350': '#63A0EE',
'blue-1-400': '#5092E7',
'blue-1-450': '#4185DC',
'blue-1-500': '#3677CC',
'blue-1-550': '#2F6ABB',
'blue-1-600': '#265EAA',
'blue-1-650': '#28528F',
'blue-1-700': '#274775',
'blue-1-750': '#243C5E',
'blue-1-800': '#20314A',
'blue-1-850': '#1B2637',
'blue-1-900': '#141C27',
'blue-1-950': '#0D1118',
'blue-2-050': '#E2F4FD',
'blue-2-100': '#C4E8F8',
'blue-2-150': '#AADCF2',
'blue-2-200': '#93CFEB',
'blue-2-250': '#7CC2E2',
'blue-2-300': '#6CB4D6',
'blue-2-350': '#5CA7C9',
'blue-2-400': '#5099BC',
'blue-2-450': '#458BAE',
'blue-2-500': '#3A7EA0',
'blue-2-550': '#327191',
'blue-2-600': '#286483',
'blue-2-650': '#2B5770',
'blue-2-700': '#294A5E',
'blue-2-750': '#263E4D',
'blue-2-800': '#22323D',
'blue-2-850': '#1C272E',
'blue-2-900': '#151D21',
'blue-2-950': '#0E1114',
'purple-050': '#F5EEFF',
'purple-100': '#ECDCFF',
'purple-150': '#E2CBFF',
'purple-200': '#D9B9FF',
'purple-250': '#D0A7FF',
'purple-300': '#C894FE',
'purple-350': '#BE83FA',
'purple-400': '#B570F5',
'purple-450': '#AB5EF0',
'purple-500': '#A04BE8',
'purple-550': '#933CDB',
'purple-600': '#8530C8',
'purple-650': '#7033A5',
'purple-700': '#5D3185',
'purple-750': '#4C2C6A',
'purple-800': '#3C2652',
'purple-850': '#2D203C',
'purple-900': '#21182A',
'purple-950': '#130F19',
'pink-050': '#FFEBF6',
'pink-100': '#FFD8ED',
'pink-150': '#FCC4E3',
'pink-200': '#F7B2D9',
'pink-250': '#F29FCE',
'pink-300': '#ED8CC3',
'pink-350': '#E779B8',
'pink-400': '#E264AD',
'pink-450': '#D2579E',
'pink-500': '#C24B8E',
'pink-550': '#B0417F',
'pink-600': '#9F3670',
'pink-650': '#873560',
'pink-700': '#6F3250',
'pink-750': '#5A2C43',
'pink-800': '#472635',
'pink-850': '#351F29',
'pink-900': '#26171D',
'pink-950': '#170F12',
'black-000': '#181B2400',
'black-050': '#181B240D',
'black-100': '#181B241A',
'black-150': '#181B2426',
'black-200': '#181B2433',
'black-250': '#181B2440',
'black-300': '#181B244D',
'black-350': '#181B2459',
'black-400': '#181B2466',
'black-450': '#181B2473',
'black-500': '#181B2480',
'black-550': '#181B248C',
'black-600': '#181B2499',
'black-650': '#181B24A6',
'black-700': '#181B24B2',
'black-750': '#181B24BF',
'black-800': '#181B24CC',
'black-850': '#181B24D9',
'black-900': '#181B24E5',
'black-950': '#0F1117F2',
'white-000': '#F6F8F900',
'white-050': '#F6F8F90D',
'white-100': '#F6F8F91A',
'white-150': '#F6F8F926',
'white-200': '#F6F8F933',
'white-250': '#F6F8F940',
'white-300': '#F6F8F94D',
'white-350': '#F6F8F959',
'white-400': '#F6F8F966',
'white-450': '#F6F8F973',
'white-500': '#F6F8F980',
'white-550': '#F6F8F98C',
'white-600': '#F6F8F999',
'white-650': '#F6F8F9A6',
'white-700': '#F6F8F9B2',
'white-750': '#F6F8F9BF',
'white-800': '#F6F8F9CC',
'white-850': '#F6F8F9D9',
'white-900': '#F6F8F9E5',
'white-950': '#F6F8F9F2',
'white-975': '#F6F8F9F9',
},
};
const whiteLabelThemes = getThemesFromGlobals(whiteLabelGlobals, {
overrides: commonTokenOverrides,
});
const dsfrThemes = getThemesFromGlobals(dsfrGlobals, {
overrides: commonTokenOverrides,
});
if (dsfrThemes.dark) {
dsfrThemes.dark.globals.components.logo.src =
'/assets/logo-gouv-darkmode.svg';
dsfrThemes.dark.globals.colors['logo-1'] = '#95ABFF';
dsfrThemes.dark.globals.colors['logo-2'] = '#E78087';
}
console.log(dsfrThemes);
const themes = {
themes: {
default: whiteLabelThemes.light,
dark: whiteLabelThemes.dark,
dsfr: dsfrThemes.light,
'dsfr-dark': dsfrThemes.dark,
},
};
const config = { ...themes };
export default config;
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.22.0",
"version": "1.23.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -15,16 +15,17 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/cunningham-tokens": "^3.1.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "^0.17.0",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@hookform/resolvers": "4.0.0",
"@openfun/cunningham-react": "3.2.3",
"@tanstack/react-query": "5.90.5",
"i18next": "25.6.0",
"i18next-browser-languagedetector": "8.2.0",
"lodash": "4.17.21",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "15.4.10",
"next": "15.5.14",
"react": "*",
"react-dom": "*",
"react-hook-form": "7.65.0",
@@ -47,7 +47,7 @@ describe('Page', () => {
render(<Page />, { wrapper: AppWrapper });
expect(mockedPush).toHaveBeenCalledWith('/teams/');
expect(mockedPush).toHaveBeenCalledWith('/mail-domains/');
});
it('checks Page rendering without team feature', () => {
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M4.04601 20.3381C3.04306 20.3381 2.28465 20.0812 1.77079 19.5673C1.25693 19.0597 1 18.3105 1 17.32V6.01815C1 5.02139 1.25693 4.26917 1.77079 3.7615C2.28465 3.25383 3.04306 3 4.04601 3H19.9447C20.9538 3 21.7154 3.25383 22.2292 3.7615C22.7431 4.26917 23 5.02139 23 6.01815V17.32C23 18.3105 22.7431 19.0597 22.2292 19.5673C21.7154 20.0812 20.9538 20.3381 19.9447 20.3381H4.04601ZM4.15745 18.5087H19.8425C20.2697 18.5087 20.5979 18.3972 20.8269 18.1743C21.056 17.9515 21.1705 17.614 21.1705 17.1621V6.16674C21.1705 5.72098 21.056 5.38666 20.8269 5.16378C20.5979 4.9409 20.2697 4.82946 19.8425 4.82946H4.15745C3.72407 4.82946 3.39285 4.9409 3.16378 5.16378C2.9409 5.38666 2.82946 5.72098 2.82946 6.16674V17.1621C2.82946 17.614 2.9409 17.9515 3.16378 18.1743C3.39285 18.3972 3.72407 18.5087 4.15745 18.5087ZM8.38286 18.8058V4.51372H10.1195V18.8058H8.38286ZM6.56268 8.22837H4.65893C4.49796 8.22837 4.35556 8.16646 4.23174 8.04263C4.11411 7.91881 4.0553 7.78261 4.0553 7.63402C4.0553 7.47305 4.11411 7.33376 4.23174 7.21613C4.35556 7.09849 4.49796 7.03968 4.65893 7.03968H6.56268C6.72984 7.03968 6.87224 7.09849 6.98987 7.21613C7.11369 7.33376 7.1756 7.47305 7.1756 7.63402C7.1756 7.78261 7.11369 7.91881 6.98987 8.04263C6.87224 8.16646 6.72984 8.22837 6.56268 8.22837ZM6.56268 10.7172H4.65893C4.49796 10.7172 4.35556 10.6584 4.23174 10.5407C4.11411 10.4169 4.0553 10.2745 4.0553 10.1136C4.0553 9.95877 4.11411 9.82257 4.23174 9.70494C4.35556 9.58731 4.49796 9.52849 4.65893 9.52849H6.56268C6.72984 9.52849 6.87224 9.58731 6.98987 9.70494C7.11369 9.82257 7.1756 9.95877 7.1756 10.1136C7.1756 10.2745 7.11369 10.4169 6.98987 10.5407C6.87224 10.6584 6.72984 10.7172 6.56268 10.7172ZM6.56268 13.1967H4.65893C4.49796 13.1967 4.35556 13.1379 4.23174 13.0203C4.11411 12.9026 4.0553 12.7664 4.0553 12.6117C4.0553 12.4507 4.11411 12.3114 4.23174 12.1938C4.35556 12.0761 4.49796 12.0173 4.65893 12.0173H6.56268C6.72984 12.0173 6.87224 12.0761 6.98987 12.1938C7.11369 12.3114 7.1756 12.4507 7.1756 12.6117C7.1756 12.7664 7.11369 12.9026 6.98987 13.0203C6.87224 13.1379 6.72984 13.1967 6.56268 13.1967Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,6 +1,13 @@
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1196 5.07095L17.3203 5.98796C17.4154 6.00474 17.4706 6.10543 17.4328 6.19433C17.2714 6.57329 17.1581 6.80005 17.0539 6.96275C16.9667 7.09909 16.8998 7.16937 16.8295 7.22281C16.7557 7.27883 16.6168 7.3625 16.3363 7.44595C15.0129 7.83959 14.2592 9.23154 14.6528 10.5549C15.0465 11.8784 16.4384 12.6321 17.7618 12.2384C19.4239 11.744 20.5126 10.8341 21.2653 9.65807C21.5989 9.13681 21.842 8.60181 22.0329 8.15355C22.1128 7.966 22.1849 7.79074 22.2525 7.62637L22.254 7.62267C22.4259 7.20484 22.8671 6.96601 23.312 7.04447C24.401 7.23649 25.1671 7.65773 25.6102 8.30817C26.0604 8.95985 26.1888 9.83372 25.9956 10.9298L23.0029 27.9022C22.8084 29.0053 22.3888 29.7825 21.7441 30.2339C21.1065 30.6865 20.2432 30.8168 19.1541 30.6248L7.96177 28.6513C6.87274 28.4593 6.10253 28.0409 5.65114 27.3963C5.20677 26.7529 5.08183 25.8796 5.27634 24.7765L8.26903 7.80411C8.4623 6.70806 8.87836 5.9302 9.51723 5.47054C10.1631 5.01212 11.0306 4.87892 12.1196 5.07095ZM13.2049 19.4392C13.6814 20.1198 14.3259 20.5317 15.1384 20.675C15.9509 20.8182 16.6974 20.6516 17.378 20.175C18.0586 19.6985 18.4705 19.054 18.6138 18.2415C18.757 17.429 18.5904 16.6825 18.1138 16.0019C17.6373 15.3213 16.9927 14.9094 16.1803 14.7661C15.3678 14.6229 14.6213 14.7895 13.9407 15.2661C13.2601 15.7426 12.8482 16.3872 12.7049 17.1996C12.5616 18.0121 12.7283 18.7586 13.2049 19.4392ZM8.27886 25.0247C8.19255 25.5142 8.5194 25.981 9.00891 26.0673L19.0539 27.8385C19.5434 27.9248 20.0102 27.598 20.0966 27.1084L20.3049 25.9267C20.3787 25.5081 20.3389 25.1045 20.1853 24.7156C20.0317 24.3268 19.7927 24.0086 19.4683 23.761C18.7724 23.2448 18.0473 22.8218 17.2931 22.4921C16.5389 22.1624 15.7555 21.9259 14.9431 21.7826C14.1306 21.6394 13.3136 21.5937 12.4921 21.6456C11.6707 21.6975 10.8446 21.8469 10.0141 22.094C9.62458 22.2157 9.29116 22.433 9.01387 22.7458C8.73658 23.0587 8.56104 23.4244 8.48724 23.8429L8.27886 25.0247Z" fill="#000091"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.694 3.88339C24.7066 4.43554 24.2692 4.89337 23.7171 4.90599C23.4094 4.91303 22.8638 4.99795 22.3424 5.23172C21.8251 5.46364 21.4224 5.79861 21.219 6.25464C21.0916 6.54023 20.9867 6.79533 20.88 7.05474C20.8115 7.22114 20.7424 7.38931 20.6661 7.56844C20.4842 7.99554 20.2799 8.43826 20.0151 8.85213C19.4512 9.73317 18.6551 10.4143 17.3473 10.8033C16.818 10.9608 16.2612 10.6593 16.1037 10.1299C15.9463 9.60059 16.2478 9.04381 16.7771 8.88635C17.6126 8.63784 18.0184 8.26165 18.3305 7.774C18.5036 7.50353 18.6559 7.18409 18.826 6.78475C18.8824 6.65241 18.9442 6.50248 19.01 6.34276C19.1273 6.05827 19.2574 5.7427 19.3924 5.44002C19.8608 4.38982 20.7275 3.76397 21.5241 3.40676C22.3167 3.05141 23.1294 2.91891 23.6714 2.90652C24.2235 2.89389 24.6813 3.33125 24.694 3.88339Z" fill="#C9191E"/>
<path d="M12.9899 3.0145C12.4754 2.81379 12.221 2.23399 12.4217 1.71946C12.6224 1.20494 13.2022 0.950538 13.7167 1.15125C14.2217 1.34824 14.9402 1.75073 15.5634 2.35572C16.0249 2.80379 16.4723 3.40191 16.7083 4.14453C16.7414 4.2487 16.6503 4.34682 16.5427 4.32784L14.5057 3.96866C14.3783 3.9462 14.2631 3.88087 14.1703 3.79076C13.7603 3.39276 13.2766 3.12635 12.9899 3.0145Z" fill="#C9191E"/>
<path d="M17.1398 10.8419C17.077 10.8132 17.0162 10.7776 16.9585 10.7348C16.6026 10.4708 16.308 10.1964 16.0646 9.91118C16.0338 9.455 16.3205 9.02217 16.7771 8.88635C17.0849 8.7948 17.3346 8.68583 17.5424 8.56059C17.6949 8.74952 17.8921 8.93714 18.1499 9.12841C18.5324 9.41212 18.6542 9.91688 18.4695 10.3349C18.1431 10.5197 17.772 10.677 17.3473 10.8033C17.2782 10.8239 17.209 10.8365 17.1398 10.8419Z" fill="#C9191E"/>
<svg width="102" height="40" viewBox="0 0 102 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9631 8.43425L21.1133 9.34236C21.2075 9.35898 21.2622 9.45868 21.2247 9.54672C21.0649 9.92201 20.9527 10.1466 20.8496 10.3077C20.7632 10.4427 20.6969 10.5123 20.6273 10.5652C20.5543 10.6207 20.4167 10.7036 20.1389 10.7862C18.8283 11.176 18.0819 12.5544 18.4717 13.865C18.8616 15.1756 20.24 15.922 21.5506 15.5321C23.1965 15.0426 24.2747 14.1414 25.02 12.9768C25.3504 12.4606 25.5911 11.9308 25.7802 11.4869C25.8593 11.3012 25.9307 11.1276 25.9977 10.9649L25.9992 10.9612C26.1693 10.5474 26.6063 10.3109 27.0469 10.3886C28.1253 10.5788 28.884 10.9959 29.3228 11.64C29.7686 12.2854 29.8958 13.1508 29.7044 14.2362L26.7407 31.0438C26.5481 32.1362 26.1326 32.9059 25.4942 33.3529C24.8628 33.8011 24.0078 33.9302 22.9294 33.74L11.8457 31.7857C10.7672 31.5955 10.0045 31.1812 9.55746 30.5428C9.11741 29.9057 8.99369 29.0409 9.1863 27.9485L12.1499 11.1409C12.3413 10.0555 12.7534 9.28516 13.386 8.82996C14.0257 8.37599 14.8847 8.24409 15.9631 8.43425ZM17.0378 22.663C17.5098 23.337 18.148 23.7449 18.9526 23.8868C19.7572 24.0287 20.4965 23.8636 21.1704 23.3917C21.8444 22.9198 22.2524 22.2815 22.3942 21.4769C22.5361 20.6724 22.3711 19.9331 21.8991 19.2591C21.4272 18.5851 20.789 18.1772 19.9844 18.0353C19.1798 17.8934 18.4405 18.0585 17.7665 18.5304C17.0925 19.0023 16.6846 19.6406 16.5428 20.4452C16.4009 21.2497 16.5659 21.989 17.0378 22.663ZM12.1597 28.1942C12.0742 28.679 12.3979 29.1413 12.8826 29.2267L22.8301 30.9808C23.3149 31.0662 23.7772 30.7425 23.8626 30.2578L24.069 29.0875C24.1421 28.673 24.1026 28.2733 23.9505 27.8882C23.7984 27.5032 23.5618 27.188 23.2405 26.9428C22.5513 26.4317 21.8333 26.0128 21.0864 25.6863C20.3395 25.3598 19.5638 25.1256 18.7592 24.9837C17.9546 24.8418 17.1456 24.7966 16.332 24.848C15.5185 24.8994 14.7006 25.0474 13.8781 25.292C13.4923 25.4125 13.1621 25.6277 12.8876 25.9375C12.613 26.2473 12.4391 26.6095 12.366 27.024L12.1597 28.1942Z" fill="#2845C1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.4153 7.25835C28.4278 7.80513 27.9947 8.25852 27.4479 8.27102C27.1432 8.27799 26.6029 8.36208 26.0866 8.59358C25.5744 8.82325 25.1755 9.15497 24.9741 9.60658C24.8479 9.88939 24.744 10.142 24.6384 10.3989C24.5706 10.5637 24.5021 10.7302 24.4266 10.9076C24.2464 11.3306 24.0442 11.769 23.7819 12.1788C23.2235 13.0513 22.4351 13.7259 21.14 14.1111C20.6158 14.267 20.0644 13.9685 19.9085 13.4443C19.7526 12.92 20.0512 12.3687 20.5754 12.2127C21.4027 11.9666 21.8046 11.5941 22.1137 11.1112C22.2851 10.8433 22.4359 10.527 22.6044 10.1315C22.6602 10.0005 22.7214 9.85201 22.7866 9.69384C22.9027 9.41211 23.0316 9.0996 23.1652 8.79986C23.6291 7.75986 24.4873 7.14008 25.2763 6.78635C26.0611 6.43445 26.866 6.30323 27.4027 6.29096C27.9494 6.27845 28.4028 6.71157 28.4153 7.25835Z" fill="#C83F49"/>
<path d="M16.8249 6.39789C16.3154 6.19914 16.0634 5.62496 16.2622 5.11543C16.461 4.6059 17.0351 4.35397 17.5447 4.55273C18.0448 4.74781 18.7562 5.14639 19.3734 5.74551C19.8305 6.18923 20.2735 6.78154 20.5072 7.51695C20.5399 7.6201 20.4498 7.71728 20.3432 7.69848L18.326 7.34279C18.1998 7.32055 18.0858 7.25585 17.9938 7.16661C17.5878 6.77248 17.1088 6.50865 16.8249 6.39789Z" fill="#C83F49"/>
<path d="M20.9346 14.1493C20.8723 14.1209 20.8121 14.0856 20.755 14.0432C20.4026 13.7818 20.1108 13.5101 19.8697 13.2276C19.8392 12.7759 20.1232 12.3472 20.5754 12.2127C20.8802 12.1221 21.1274 12.0142 21.3332 11.8901C21.4842 12.0772 21.6795 12.263 21.9349 12.4524C22.3136 12.7334 22.4342 13.2333 22.2513 13.6472C21.9281 13.8303 21.5606 13.986 21.14 14.1111C21.0716 14.1315 21.003 14.144 20.9346 14.1493Z" fill="#C83F49"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.4198 15.5822C71.2645 15.5822 72.0007 15.7275 72.6302 16.0144C72.6566 16.0264 72.6853 16.0329 72.7143 16.0329H76.6529C76.7622 16.0329 76.8509 16.1216 76.8509 16.2309V17.9566C76.8509 18.066 76.7622 18.1547 76.6529 18.1547H74.9582C74.8185 18.1547 74.7224 18.2957 74.7635 18.4292C74.8753 18.793 74.9322 19.1752 74.9322 19.5763C74.9322 20.3132 74.7526 20.9905 74.3916 21.6071C74.0457 22.2238 73.5337 22.7122 72.8569 23.0732C72.1799 23.4343 71.3675 23.6148 70.4198 23.6148C69.9907 23.6148 69.5888 23.5759 69.2138 23.4993C69.1523 23.4868 69.0878 23.5023 69.0418 23.545C68.9855 23.5972 68.9329 23.6505 68.8851 23.7057C68.6899 23.9161 68.5922 24.1413 68.592 24.3817C68.592 24.6072 68.6599 24.7879 68.7951 24.9233C68.9305 25.0436 69.141 25.1041 69.4266 25.1041H72.4507C73.4284 25.1041 74.2409 25.2621 74.8877 25.578C75.5496 25.8788 76.0379 26.3004 76.3538 26.842C76.6848 27.3835 76.8509 28 76.8509 28.692C76.8509 29.3087 76.7073 29.8801 76.4215 30.4066C76.1508 30.9331 75.7527 31.3926 75.2262 31.7837C74.7149 32.1898 74.098 32.4977 73.3762 32.7083C72.6541 32.9339 71.8561 33.0467 70.9836 33.0467C69.8104 33.0467 68.7947 32.9041 67.9373 32.6183C67.0951 32.3476 66.4404 31.9413 65.9742 31.3998C65.523 30.8584 65.2973 30.1964 65.2972 29.4144C65.2972 28.7074 65.5085 28.0827 65.9297 27.5412C66.1454 27.2715 66.4063 27.0132 66.7127 26.7671C66.8097 26.6892 66.8169 26.5428 66.7339 26.45C66.6244 26.3276 66.5289 26.195 66.448 26.0518C66.2676 25.7059 66.1772 25.3447 66.1772 24.9687C66.1773 24.3821 66.3578 23.863 66.7188 23.4117C66.8658 23.2294 67.0444 23.057 67.2537 22.8934C67.3604 22.8101 67.3642 22.6465 67.2619 22.5578C66.9468 22.2847 66.6828 21.9684 66.4712 21.6071C66.1103 20.9905 65.9297 20.3132 65.9297 19.5763C65.9297 18.8542 66.1102 18.1916 66.4712 17.5899C66.8322 16.9734 67.3434 16.4847 68.005 16.1238C68.682 15.7628 69.4872 15.5822 70.4198 15.5822ZM68.7065 27.2481C68.6601 27.2481 68.6149 27.2644 68.5798 27.2947C68.3541 27.4896 68.1547 27.6995 67.9828 27.9251C67.7572 28.256 67.6443 28.647 67.6443 29.0982C67.6443 29.4893 67.7725 29.8278 68.0282 30.1136C68.2989 30.4143 68.6747 30.6401 69.1558 30.7905C69.6523 30.956 70.2544 31.0391 70.9614 31.0391C71.6985 31.0391 72.3232 30.9488 72.8346 30.7683C73.3459 30.6028 73.7298 30.3545 73.9855 30.0236C74.2562 29.7078 74.3916 29.3385 74.3916 28.9173C74.3916 28.6017 74.3237 28.316 74.1885 28.0605C74.0532 27.8199 73.8273 27.6239 73.5116 27.4735C73.1957 27.323 72.7587 27.2481 72.2022 27.2481H68.7065ZM70.4421 17.4545C69.9759 17.4545 69.5695 17.553 69.2235 17.7485C68.8928 17.929 68.6371 18.1774 68.4566 18.4931C68.2913 18.809 68.2081 19.1702 68.2081 19.5763C68.2082 19.9823 68.2912 20.3508 68.4566 20.6816C68.6371 21.0125 68.8928 21.268 69.2235 21.4485C69.5695 21.629 69.9759 21.7193 70.4421 21.7193C70.9084 21.7193 71.3074 21.6291 71.6383 21.4485C71.9692 21.253 72.2176 20.9975 72.383 20.6816C72.5635 20.3508 72.6537 19.9823 72.6538 19.5763C72.6538 19.1551 72.5635 18.7858 72.383 18.4699C72.2175 18.1541 71.9692 17.9057 71.6383 17.7253C71.3074 17.5448 70.9084 17.4545 70.4421 17.4545Z" fill="#2845C1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M59.0663 15.5822C59.8483 15.5822 60.5554 15.7096 61.1871 15.9652C61.8188 16.2209 62.3606 16.5749 62.8118 17.0261C63.2781 17.4774 63.632 18.0118 63.8727 18.6285C64.1133 19.2451 64.2334 19.9143 64.2334 20.6362C64.2334 20.907 64.219 21.1706 64.1889 21.4263C64.1671 21.6115 64.1412 21.773 64.1114 21.9108C64.0925 21.9981 64.0138 22.0578 63.9245 22.0578H56.0046C55.8896 22.0578 55.7984 22.1555 55.8105 22.2698C55.8739 22.868 56.0185 23.384 56.2463 23.8179C56.5621 24.4195 57.0055 24.8709 57.577 25.1718C58.1486 25.4727 58.7959 25.6234 59.5179 25.6234C60.1948 25.6234 60.8042 25.488 61.3457 25.2173C61.8337 24.9798 62.2348 24.6727 62.5493 24.2964C62.6214 24.2101 62.7497 24.1925 62.8376 24.2626L64.3098 25.4363C64.3934 25.5029 64.4093 25.624 64.3426 25.7074C63.8329 26.3439 63.1869 26.8575 62.4056 27.2481C61.5633 27.6541 60.608 27.8574 59.5402 27.8574C58.2165 27.8574 57.088 27.5784 56.1554 27.0218C55.2229 26.4502 54.5087 25.6982 54.0123 24.7656C53.5159 23.833 53.2677 22.8175 53.2677 21.7193C53.2677 20.862 53.395 20.0647 53.6506 19.3277C53.9214 18.5906 54.305 17.9434 54.8015 17.3868C55.2978 16.8153 55.9073 16.372 56.6292 16.0561C57.3513 15.7402 58.1638 15.5822 59.0663 15.5822ZM58.9986 17.6353C58.3369 17.6354 57.758 17.7862 57.2617 18.087C56.7804 18.3878 56.4111 18.8311 56.1554 19.4177C56.0726 19.6028 56.0045 19.8025 55.9492 20.0163C55.9178 20.1378 56.012 20.2532 56.1375 20.2532H61.5891C61.7009 20.2532 61.7913 20.1606 61.7796 20.0493C61.7421 19.6925 61.6355 19.3543 61.4579 19.0347C61.2473 18.6136 60.9394 18.275 60.5334 18.0193C60.1272 17.7635 59.6154 17.6353 58.9986 17.6353Z" fill="#2845C1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M88.7228 15.5822C89.5048 15.5822 90.2119 15.7096 90.8436 15.9652C91.4753 16.2209 92.0171 16.5749 92.4683 17.0261C92.9346 17.4774 93.2885 18.0118 93.5292 18.6285C93.7698 19.2451 93.8899 19.9143 93.8899 20.6362C93.8899 20.907 93.8755 21.1706 93.8454 21.4263C93.8236 21.6115 93.7977 21.773 93.7679 21.9108C93.7491 21.9981 93.6703 22.0578 93.581 22.0578H85.6611C85.5461 22.0578 85.455 22.1555 85.4671 22.2698C85.5304 22.868 85.675 23.384 85.9028 23.8179C86.2187 24.4195 86.662 24.8709 87.2335 25.1718C87.8051 25.4727 88.4524 25.6234 89.1744 25.6234C89.8513 25.6234 90.4607 25.488 91.0022 25.2173C91.4902 24.9798 91.8913 24.6727 92.2058 24.2964C92.2779 24.2101 92.4062 24.1925 92.4941 24.2626L93.9664 25.4363C94.0499 25.5029 94.0659 25.624 93.9991 25.7074C93.4894 26.3439 92.8434 26.8575 92.0621 27.2481C91.2198 27.6542 90.2646 27.8574 89.1967 27.8574C87.8729 27.8574 86.7445 27.5784 85.8119 27.0218C84.8794 26.4502 84.1652 25.6982 83.6688 24.7656C83.1724 23.833 82.9242 22.8175 82.9242 21.7193C82.9242 20.862 83.0515 20.0647 83.3071 19.3277C83.5779 18.5906 83.9616 17.9434 84.458 17.3868C84.9543 16.8153 85.5638 16.372 86.2858 16.0561C87.0078 15.7402 87.8203 15.5822 88.7228 15.5822ZM88.6551 17.6353C87.9934 17.6354 87.4146 17.7862 86.9182 18.087C86.4369 18.3878 86.0676 18.8311 85.8119 19.4177C85.7291 19.6028 85.661 19.8025 85.6057 20.0163C85.5743 20.1378 85.6685 20.2532 85.794 20.2532H91.2456C91.3574 20.2532 91.4478 20.1606 91.4361 20.0493C91.3986 19.6925 91.292 19.3543 91.1144 19.0347C90.9038 18.6136 90.5959 18.275 90.1899 18.0193C89.7837 17.7635 89.2719 17.6353 88.6551 17.6353Z" fill="#2845C1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.1829 11.6104C46.8676 11.6105 48.191 12.0248 49.1537 12.8522C50.1314 13.6795 50.6207 14.7998 50.6208 16.2137C50.6208 17.1613 50.395 17.9811 49.9438 18.673C49.5076 19.35 48.8829 19.8772 48.0706 20.2532C48.045 20.2648 48.0353 20.295 48.0514 20.318L52.7942 27.0941C52.8861 27.2254 52.7922 27.4058 52.632 27.4058H49.8695C49.803 27.4058 49.7409 27.3723 49.7043 27.3168L45.4859 20.9267C45.4297 20.8416 45.3338 20.7925 45.2318 20.7943C45.2155 20.7946 45.1992 20.7948 45.1829 20.7948H43.5754C43.466 20.7948 43.3773 20.8835 43.3773 20.9928V27.2077C43.3773 27.3171 43.2887 27.4058 43.1793 27.4058H40.7999C40.6905 27.4058 40.6018 27.3171 40.6018 27.2077V11.8085C40.6018 11.6991 40.6905 11.6104 40.7999 11.6104H45.1829ZM43.3773 18.2951C43.3773 18.4045 43.466 18.4931 43.5754 18.4931H45.3183C46.1002 18.4931 46.7017 18.2898 47.1228 17.8839C47.544 17.4627 47.7553 16.8903 47.7553 16.1683C47.7552 15.4915 47.544 14.9497 47.1228 14.5436C46.7017 14.1377 46.1001 13.9353 45.3183 13.9353H43.5754C43.466 13.9353 43.3773 14.024 43.3773 14.1333V18.2951Z" fill="#2845C1"/>
<path d="M80.7927 27.2077C80.7927 27.3171 80.7041 27.4058 80.5947 27.4058H78.5093C78.3999 27.4058 78.3112 27.3171 78.3112 27.2077V16.2309C78.3112 16.1216 78.3999 16.0329 78.5093 16.0329H80.5947C80.7041 16.0329 80.7927 16.1216 80.7927 16.2309V27.2077Z" fill="#2845C1"/>
<path d="M60.4572 14.1456C60.42 14.1975 60.3601 14.2283 60.2962 14.2283H58.3846C58.2293 14.2283 58.1344 14.0578 58.2163 13.9258L60.2944 10.5764C60.3305 10.5182 60.3942 10.4828 60.4627 10.4828H62.6969C62.8582 10.4828 62.9519 10.6652 62.8579 10.7962L60.4572 14.1456Z" fill="#2845C1"/>
<path d="M79.5742 10.3474C79.875 10.3474 80.1459 10.4224 80.3866 10.5727C80.6423 10.7232 80.8382 10.9263 80.9736 11.182C81.124 11.4227 81.1989 11.6935 81.1989 11.9944C81.1989 12.2951 81.1239 12.5733 80.9736 12.8289C80.8382 13.0847 80.6423 13.2878 80.3866 13.4382C80.1459 13.5736 79.875 13.6413 79.5742 13.6413C79.2734 13.6413 78.9953 13.5736 78.7396 13.4382C78.4839 13.2878 78.2808 13.0847 78.1304 12.8289C77.98 12.5733 77.905 12.2951 77.905 11.9944C77.905 11.5431 78.063 11.1594 78.3789 10.8435C78.7098 10.5127 79.1081 10.3475 79.5742 10.3474Z" fill="#2845C1"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+112 -12
View File
@@ -1,4 +1,4 @@
import { ComponentPropsWithRef } from 'react';
import { ComponentPropsWithRef, HTMLElementType } from 'react';
import styled from 'styled-components';
import { CSSProperties, RuleSet } from 'styled-components/dist/types';
@@ -11,11 +11,13 @@ import {
import { hideEffect, showEffect } from './Effect';
export interface BoxProps {
as?: keyof HTMLElementTagNameMap;
as?: HTMLElementType;
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$border?: CSSProperties['border'];
$color?: CSSProperties['color'];
$css?: string | RuleSet<object>;
$cursor?: CSSProperties['cursor'];
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
@@ -24,6 +26,7 @@ export interface BoxProps {
$hasTransition?: boolean | 'slow';
$height?: CSSProperties['height'];
$justify?: CSSProperties['justifyContent'];
$opacity?: CSSProperties['opacity'];
$overflow?: CSSProperties['overflow'];
$margin?: MarginPadding;
$maxHeight?: CSSProperties['maxHeight'];
@@ -36,28 +39,44 @@ export interface BoxProps {
$shrink?: CSSProperties['flexShrink'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
$zIndex?: CSSProperties['zIndex'];
$wrap?: CSSProperties['flexWrap'];
// Theming props
$layer?: 'background' | 'content' | 'border';
$theme?:
| 'brand'
| 'error'
| 'gray'
| 'info'
| 'success'
| 'warning'
| 'neutral'
| 'contextual'
| 'disabled'
| (string & {});
$scope?: 'surface' | 'semantic' | 'palette' | (string & {});
$variation?: 'primary' | 'secondary' | 'tertiary' | (string & {});
$withThemeBG?: boolean;
$withThemeBorder?: boolean;
$withThemeInherited?: boolean;
}
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
display: flex;
flex-direction: column;
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}
${({ $direction }) => $direction && `flex-direction: ${$direction};`}
${({ $display }) => $display && `display: ${$display};`}
${({ $cursor }) => $cursor && `cursor: ${$cursor};`}
${({ $direction }) => `flex-direction: ${$direction || 'column'};`}
${({ $display, as }) =>
`display: ${$display || (as?.match('span|input') ? 'inline-flex' : 'flex')};`}
${({ $flex }) => $flex && `flex: ${$flex};`}
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
$hasTransition && $hasTransition === 'slow'
? `transition: all 0.5s ease-in-out;`
? `transition: all 0.5s var(--c--globals--transitions--ease-out);`
: $hasTransition
? `transition: all 0.3s ease-in-out;`
? `transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);`
: ''}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $margin }) => $margin && stylesMargin($margin)}
@@ -65,11 +84,92 @@ export const Box = styled('div')<BoxProps>`
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight};`}
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
${({ $opacity }) => $opacity && `opacity: ${$opacity};`}
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
${({ $padding }) => $padding && stylesPadding($padding)}
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
${({
$layer = 'border',
$theme = 'brand',
$variation = 'primary',
$scope = 'semantic',
$border,
$withThemeBorder,
$withThemeInherited,
}) => {
if ($border) {
return `border: ${$border};`;
}
if (!$layer || !$scope || !$theme || !$withThemeBorder) {
return '';
}
if ($withThemeInherited) {
return `border: inherit;`;
}
return `border: 1px solid var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({
$layer = 'background',
$theme = 'brand',
$variation = 'primary',
$scope = 'semantic',
$background,
$withThemeBG,
$withThemeInherited,
}) => {
if ($background) {
return `background: ${$background};`;
}
if (!$layer || !$scope || !$theme || !$withThemeBG) {
return '';
}
if ($withThemeInherited) {
return `background: inherit;`;
}
return `background: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({
$layer = 'content',
$theme = 'neutral',
$variation = 'primary',
$scope = 'semantic',
$color,
$withThemeBG,
$withThemeInherited,
}) => {
if ($color) {
return `color: ${$color};`;
}
if (!$layer || !$scope) {
return '';
}
// There is a special case when primary with background
if (
$withThemeBG &&
$layer === 'content' &&
$scope === 'semantic' &&
$variation === 'primary' &&
$theme
) {
$variation = `on-${$theme}`;
}
if ($withThemeInherited) {
return `color: inherit;`;
}
return `color: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
@@ -89,7 +189,7 @@ export const Box = styled('div')<BoxProps>`
return (
effect &&
`
transition: all 0.3s ease-in-out;
transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);
${effect}
`
);
@@ -39,7 +39,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
font-family: inherit;
color: ${props.disabled
? 'var(--c--theme--colors--greyscale-400) !important'
? 'var(--c--theme--colors--gray-400) !important'
: 'inherit'};
${$css || ''}
`}
@@ -12,7 +12,7 @@ export const Card = ({ children, ...props }: PropsWithChildren<BoxType>) => {
$background="white"
$radius="4px"
$css={`
border: 1px solid ${colorsTokens()['greyscale-050']};
border: 1px solid ${colorsTokens()['gray-050']};
`}
{...props}
>
@@ -40,6 +40,8 @@ interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
/** Accessible name for the trigger button (recommended for screen readers). */
ariaLabel?: string;
}
export const DropButton = ({
@@ -47,6 +49,7 @@ export const DropButton = ({
isOpen = false,
onOpenChange,
children,
ariaLabel,
}: PropsWithChildren<DropButtonProps>) => {
const [opacity, setOpacity] = useState(false);
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
@@ -75,7 +78,7 @@ export const DropButton = ({
<>
<GlobalStyle />
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
<StyledButton>{button}</StyledButton>
<StyledButton aria-label={ariaLabel}>{button}</StyledButton>
<StyledPopover
style={{ opacity: opacity ? 1 : 0 }}
isOpen={isLocalOpen}
+8 -33
View File
@@ -1,41 +1,17 @@
import clsx from 'clsx';
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type IconProps = TextType & {
iconName: string;
};
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
};
interface IconBGProps extends TextType {
iconName: string;
}
export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Text
$isMaterialIcon
$size="36px"
$theme="primary"
$variation="600"
$background={colorsTokens()['primary-050']}
$css={`
border: 1px solid ${colorsTokens()['primary-200']};
user-select: none;
`}
$radius="12px"
$padding="4px"
$margin="auto"
{...textProps}
aria-hidden="true"
className={clsx('material-icons', textProps.className)}
>
{iconName}
</Text>
@@ -48,16 +24,15 @@ type IconOptionsProps = TextType & {
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
return (
<Text
<Icon
{...props}
$isMaterialIcon
$theme="primary"
aria-hidden="true"
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
$css={css`
color: var(--c--contextuals--content--semantic--brand--tertiary);
user-select: none;
${props.$css}
`}
>
{isHorizontal ? 'more_horiz' : 'more_vert'}
</Text>
/>
);
};
+24 -22
View File
@@ -12,32 +12,34 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box $display="flex" $gap="4px">
<label
htmlFor={label}
style={{ fontWeight: 500, color: colorsTokens()['greyscale-900'] }}
>
{label} {required && '*'}
</label>
<Box>
<Box $display="flex" $gap="8px" $margin={{ bottom: 'sm' }}>
<label
htmlFor={label}
style={{ fontWeight: 500, color: colorsTokens()['gray-900'] }}
>
{label} {required && '*'}
</label>
<input
id={label}
aria-required={required}
required={required}
style={{
padding: '12px',
borderRadius: '4px',
fontSize: '14px',
border: `1px solid ${error ? colorsTokens()['error-500'] : 'var(--c--contextuals--border--semantic--neutral--tertiary)'}`,
background: colorsTokens()['gray-000'],
color: colorsTokens()['gray-550'],
}}
{...props}
/>
</Box>
{error && (
<Text $size="xs" $theme="danger" $variation="600">
<Text $size="xs" $theme="error" $variation="secondary">
{error}
</Text>
)}
<input
id={label}
aria-required={required}
required={required}
style={{
padding: '12px',
borderRadius: '4px',
fontSize: '14px',
border: `1px solid ${error ? colorsTokens()['danger-500'] : colorsTokens()['greyscale-400']}`,
background: colorsTokens()['greyscale-050'],
color: colorsTokens()['greyscale-900'],
}}
{...props}
/>
</Box>
);
};
@@ -0,0 +1,89 @@
import React from 'react';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export type LocalInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
right: React.ReactNode;
error?: string;
required?: boolean;
id: string;
};
export const LocalInput = ({
label,
right,
error,
required,
id,
...inputProps
}: LocalInputProps) => {
const { colorsTokens } = useCunninghamTheme();
const borderColor = error
? colorsTokens()['error-500']
: 'var(--c--contextuals--border--semantic--neutral--tertiary)';
return (
<Box>
<Box $margin={{ bottom: 'sm' }}>
<label
htmlFor={id}
style={{ fontWeight: 500, color: colorsTokens()['gray-900'] }}
>
{label} {required && '*'}
</label>
</Box>
<div
style={{
display: 'flex',
flexDirection: 'row',
flexWrap: 'nowrap',
alignItems: 'stretch',
border: `1px solid ${borderColor}`,
borderRadius: 4,
overflow: 'hidden',
background: colorsTokens()['gray-000'],
}}
>
<input
id={id}
aria-required={required}
required={required}
style={{
flex: '1 1 0',
minWidth: 0,
padding: '12px',
border: 'none',
borderRadius: 0,
fontSize: 14,
background: 'transparent',
color: colorsTokens()['gray-550'],
outline: 'none',
}}
{...inputProps}
/>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
flexShrink: 0,
background: 'transparent',
}}
>
{right}
</div>
</div>
{error && (
<Text
$size="xs"
$theme="error"
$variation="secondary"
$margin={{ top: '2xs' }}
>
{error}
</Text>
)}
</Box>
);
};
@@ -1,7 +1,7 @@
import {
Modal as CunninghamModal,
ModalProps,
} from '@openfun/cunningham-react';
} from '@gouvfr-lasuite/cunningham-react';
import React, { useEffect } from 'react';
// Define a wrapper component that extends ModalProps to accept the same props as the Modal
+38 -11
View File
@@ -1,7 +1,7 @@
import { Tooltip } from '@openfun/cunningham-react';
import { Tooltip } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
interface TagContentProps {
@@ -9,41 +9,68 @@ interface TagContentProps {
showTooltip?: boolean;
tooltipType?: 'domain' | 'mail';
placement?: 'top' | 'bottom';
showIcon?: boolean;
}
const TagContent = ({ status }: TagContentProps) => {
const TagContent = ({ status, showIcon }: TagContentProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const translatedStatus = {
pending: t('pending'),
enabled: t('enabled'),
disabled: t('disabled'),
action_required: t('action required'),
failed: t('failed'),
};
const textColor = {
pending: colorsTokens()['info-600'],
enabled: colorsTokens()['success-600'],
disabled: colorsTokens()['greyscale-600'],
disabled: colorsTokens()['gray-600'],
action_required: colorsTokens()['warning-600'],
failed: colorsTokens()['danger-600'],
failed: colorsTokens()['error-600'],
};
const backgroundColor = {
pending: colorsTokens()['info-100'],
enabled: colorsTokens()['success-100'],
disabled: colorsTokens()['greyscale-100'],
disabled: colorsTokens()['gray-100'],
action_required: colorsTokens()['warning-100'],
failed: colorsTokens()['danger-100'],
failed: colorsTokens()['error-100'],
};
return (
<Box
$display="flex"
$direction="row"
$gap="4px"
$background={backgroundColor[status]}
$color={textColor[status]}
$radius="4px"
$css={`
padding: 4px 8px;
font-weight: 600;
cursor: default;
text-transform: capitalize;
`}
>
{t(status).replace('_', ' ')}
{translatedStatus[status]}
{showIcon &&
(status === 'enabled' ? (
<Icon
$size="small"
$weight="bold"
iconName="check"
$color="inherit"
/>
) : (
<Icon
$size="small"
$weight="bold"
iconName="arrow_forward"
$color="inherit"
/>
))}
</Box>
);
};
@@ -80,10 +107,10 @@ export const Tag = ({ ...props }: TagContentProps) => {
return props.showTooltip ? (
<Tooltip content={tooltipContent} placement={props.placement || 'top'}>
<Box>
<TagContent status={props.status} />
<TagContent status={props.status} showIcon={props.showIcon} />
</Box>
</Tooltip>
) : (
<TagContent status={props.status} />
<TagContent status={props.status} showIcon={props.showIcon} />
);
};
+10 -44
View File
@@ -5,70 +5,36 @@ import { tokens } from '@/cunningham';
import { Box, BoxProps } from './Box';
const { sizes } = tokens.themes.default.theme.font;
const { sizes } = tokens.themes.default.globals.font;
type TextSizes = keyof typeof sizes;
export interface TextProps extends BoxProps {
as?: keyof Pick<
HTMLElementTagNameMap,
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>;
$elipsis?: boolean;
$isMaterialIcon?: boolean;
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
$ellipsis?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$textTransform?: CSSProperties['textTransform'];
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'secondary'
| 'info'
| 'success'
| 'warning'
| 'danger'
| 'greyscale';
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
| '400'
| '500'
| '600'
| '700'
| '800'
| '900'
| '1000';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $textTransform }) =>
$textTransform && `text-transform: ${$textTransform};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}
${({ $size }) =>
$size &&
`font-size: ${$size in sizes ? sizes[$size as TextSizes] : $size};`}
${({ $theme, $variation }) =>
`color: var(--c--theme--colors--${$theme}-${$variation});`}
${({ $color }) => $color && `color: ${$color};`}
${({ $elipsis }) =>
$elipsis &&
${({ $ellipsis }) =>
$ellipsis &&
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, $isMaterialIcon, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
(props, ref) => {
return <TextStyled ref={ref} as="span" {...props} />;
},
);
@@ -5,6 +5,7 @@ export * from './Effect';
export * from './DropButton';
export * from './Icon';
export * from './Input';
export * from './LocalInput';
export * from './Link';
export * from './LogoGouv';
export * from './Tag';
@@ -1,7 +1,7 @@
import {
Modal as CunninghamModal,
ModalProps,
} from '@openfun/cunningham-react';
} from '@gouvfr-lasuite/cunningham-react';
import React, { useEffect } from 'react';
import { HorizontalSeparator } from '@/components';
@@ -33,6 +33,8 @@ export const CustomModal: React.FC<
<HorizontalSeparator $withPadding={true}></HorizontalSeparator>
<div>
{children}
<HorizontalSeparator $withPadding={true}></HorizontalSeparator>
{/* modal content */}
{/*<div className={style.content}>{children}</div>*/}
@@ -23,11 +23,16 @@
border: none;
font-size: 34px;
cursor: pointer;
color: var(--c--theme--colors--greyscale-700);
color: var(--c--theme--colors--gray-700);
}
.content {
padding: var(--c--theme--spacings--md);
padding: 0 !important;
}
.modalCustom .c__modal__content {
padding: 0 !important;
}
.c__modal__footer {
@@ -42,7 +47,7 @@
padding: 0 var(--c--theme--spacings--md);
padding-top: 0;
align-items: center;
border-top: 1px solid var(--c--theme--colors--greyscale-200);
border-top: 1px solid var(--c--theme--colors--gray-200);
}
.progressBar {
@@ -63,9 +68,9 @@
width: auto;
flex-grow: 1;
border-radius: 4px;
background-color: var(--c--theme--colors--primary-200);
background-color: var(--c--theme--colors--brand-200);
&.active {
background-color: var(--c--theme--colors--primary-800);
background-color: var(--c--theme--colors--brand-800);
}
}
@@ -26,7 +26,7 @@ export const HorizontalSeparator = ({
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens()['greyscale-100']
: colorsTokens()['gray-100']
}
/>
);
@@ -1,8 +1,6 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
@@ -13,15 +11,14 @@ export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
return (
<Box
$css={css`
width: 100%;
${showSeparator &&
css`
border-bottom: 1px solid ${colors?.['greyscale-200']};
border-bottom: 1px solid
var(--c--contextuals--border--surface--primary);
`}
`}
>
@@ -1,54 +1,109 @@
import * as React from 'react';
import { ReactNode } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import clsx from 'clsx';
import React, { ReactNode, useState } from 'react';
import { Box } from '@/components';
import { Icon } from '@/components';
import style from './custom-tabs.module.scss';
type TabsOption = {
ariaLabel?: string;
export type TabData = {
id: string;
label: string;
iconName?: string;
id?: string;
icon?: ReactNode;
subtext?: string;
content: ReactNode;
};
type Props = {
tabs: TabsOption[];
export type TabsProps = {
theme?: 'brand' | 'neutral';
orientation?: 'horizontal' | 'vertical';
tabs: TabData[];
defaultSelectedTab?: string;
fullWidth?: boolean;
};
export const CustomTabs = ({ tabs }: Props) => {
export const CustomTabs = ({
orientation = 'horizontal',
theme = 'brand',
tabs,
defaultSelectedTab,
fullWidth = false,
}: TabsProps) => {
const [selectedTabId, setSelectedTabId] = useState(
defaultSelectedTab ?? tabs[0]?.id,
);
const activeTab =
tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0] ?? null;
if (tabs.length === 0 || !activeTab) {
return null;
}
return (
<div className={style.customTabsContainer}>
<Tabs>
<TabList>
<div
className={clsx(style.root, {
[style.fullWidth]: fullWidth,
[style.neutral]: theme === 'neutral',
[style.orientationVertical]: orientation === 'vertical',
})}
>
<div
style={{
display: 'flex',
}}
>
<div
style={{
display: 'inline-flex',
width: 'auto',
flexDirection: orientation === 'horizontal' ? 'row' : 'column',
alignItems: 'center',
borderRadius: 8,
backgroundColor:
'var(--c--contextuals--background--surface--primary)',
backdropFilter: 'blur(10px)',
border: '1px solid var(--c--contextuals--border--surface--primary)',
padding: 4,
boxShadow: 'rgba(0, 0, 0, 0.05) 0px 2px 4px 0px',
gap: 4,
}}
>
{tabs.map((tab) => {
const id = tab.id ?? tab.label;
const isSelected = tab.id === activeTab?.id;
return (
<Tab key={id} aria-label={tab.ariaLabel} id={id}>
<Box $direction="row" $gap="5px">
{tab.iconName && (
<span className="material-icons" aria-hidden="true">
{tab.iconName}
<Button
key={tab.id}
onClick={() => setSelectedTabId(tab.id)}
variant={isSelected ? 'secondary' : 'tertiary'}
color={isSelected ? 'brand' : 'neutral'}
size="small"
aria-pressed={isSelected}
icon={
<Icon
iconName={tab.iconName ?? ''}
$color={isSelected ? 'brand' : 'neutral'}
/>
}
>
{tab.icon && !tab.iconName && tab.icon}
<span>
<span className="react-aria-Tab__title">{tab.label}</span>
{tab.subtext && orientation === 'vertical' && (
<span className="react-aria-Tab__subtext">
{tab.subtext}
</span>
)}
{tab.label}
</Box>
</Tab>
</span>
</Button>
);
})}
</TabList>
{tabs.map((tab) => {
const id = tab.id ?? tab.label;
return (
<TabPanel key={id} id={id}>
{tab.content}
</TabPanel>
);
})}
</Tabs>
</div>
</div>
{activeTab && (
<div style={{ marginTop: 8, width: '100%' }}>{activeTab.content}</div>
)}
</div>
);
};
@@ -1,12 +1,18 @@
.customTabsContainer {
display: flex;
width: 100%;
margin-top: 30px;
.root {
&.fullWidth {
:global(.react-aria-Tabs) {
width: 100%;
}
:global(.react-aria-Tab) {
flex: 1;
display: flex;
justify-content: center;
}
}
:global(.react-aria-Tabs) {
width: 100%;
display: flex;
flex: 1;
margin: auto;
&[data-orientation='horizontal'] {
flex-direction: column;
@@ -15,36 +21,39 @@
:global(.react-aria-TabList) {
display: flex;
width: 100%;
gap: 25px;
}
:global(.react-aria-Tab) {
display: flex;
padding: 0 10px 10px 10px;
padding: 10px;
cursor: pointer;
font-weight: 500;
max-width: 300px;
color: var(--c--contextuals--content--semantic--brand--tertiary);
outline: none;
position: relative;
color: var(--c--theme--colors--secondary-900);
transition: color 200ms;
--border-color: transparent;
forced-color-adjust: none;
display: flex;
align-items: center;
gap: var(--c--globals--spacings--sm);
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
&[data-hovered] {
background-color: #fff;
color: var(--text-color-hover);
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
}
&[data-selected] {
font-weight: 500;
color: var(--c--theme--colors--primary-text);
border-bottom: 2px solid var(--c--theme--colors--primary-text);
padding-bottom: 9px;
border-bottom: 2px solid
var(--c--contextuals--content--semantic--brand--tertiary);
}
&[data-disabled] {
color: var(--text-color-disabled);
color: var(--c--theme--colors--gray-400);
&[data-selected] {
--border-color: var(--text-color-disabled);
--border-color: var(--c--theme--colors--gray-400);
}
}
@@ -53,17 +62,72 @@
position: absolute;
inset: 4px;
border-radius: 4px;
border: 2px solid var(--focus-ring-color);
border: 2px solid var(--c--theme--colors--brand-600);
}
}
:global(.react-aria-TabPanel) {
margin-top: 15px;
margin-top: 4px;
padding: 10px;
border-radius: 4px;
outline: none;
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
outline: 2px solid var(--c--theme--colors--brand-600);
}
}
&.neutral {
:global(.react-aria-Tab) {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
&[data-selected] {
border-color: var(
--c--contextuals--content--semantic--neutral--tertiary
);
}
}
}
&.orientationVertical {
:global(.react-aria-Tabs) {
width: 100%;
}
:global(.react-aria-TabList) {
width: 100%;
flex-direction: column;
gap: 10px;
}
:global(.react-aria-Tab) {
color: var(--c--contextuals--content--semantic--neutral--primary);
border-radius: 4px;
border: none !important;
font-weight: 500;
letter-spacing: -0.01em;
font-size: var(--c--globals--font--sizes--sm);
line-height: var(--c--globals--font--sizes--md);
&[data-hovered] {
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
}
:global(.react-aria-Tab__subtext) {
display: block;
font-weight: 400;
line-height: var(--c--globals--font--sizes--md);
font-size: var(--c--globals--font--sizes--xs);
color: var(--c--contextuals--content--semantic--neutral--secondary);
}
&[data-selected] {
font-weight: 700;
padding-bottom: 10px;
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
border: none;
}
}
}
}
@@ -1,4 +1,4 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { CunninghamProvider } from '@gouvfr-lasuite/ui-kit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -27,13 +27,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<CunninghamProvider theme={theme}>
<CunninghamProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
</CunninghamProvider>
</QueryClientProvider>
</QueryClientProvider>
</CunninghamProvider>
);
}
@@ -1,4 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { PropsWithChildren, useEffect } from 'react';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/core/auth';
@@ -8,7 +8,12 @@ export const ButtonLogin = () => {
const { logout } = useAuthStore();
return (
<Button onClick={logout} color="primary-text" aria-label={t('Logout')}>
<Button
onClick={logout}
color="brand"
variant="tertiary"
aria-label={t('Logout')}
>
{t('Logout')}
</Button>
);
@@ -1,20 +1,46 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { UserMenu } from '@gouvfr-lasuite/ui-kit';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { baseApiUrl } from '@/api';
import { useAuthStore } from '@/core/auth';
import { LanguagePicker } from '@/features/language';
interface UserMenuUser {
email: string;
full_name: string;
organization?: string;
}
export const UserInfo = () => {
const { t } = useTranslation();
const { authenticated, userData } = useAuthStore();
const userName = userData?.name || userData?.email || t('No Username');
const organizationName = userData?.organization?.name || '';
const { authenticated, userData, logout } = useAuthStore();
const user: UserMenuUser = {
email: userData?.email ?? '',
full_name: userData?.name ?? '',
organization:
typeof userData?.organization === 'string'
? userData.organization
: (userData?.organization?.name ?? ''),
};
return authenticated ? (
<Box $direction="column" $align="left">
<Text $theme="primary">
{userName}
{organizationName ? ` | ${organizationName}` : ''}
</Text>
</Box>
) : null;
if (!authenticated) {
return null;
}
if (!userData) {
return (
<Button
color="brand"
variant="tertiary"
onClick={() =>
window.location.assign(new URL('authenticate/', baseApiUrl()).href)
}
>
{t('Sign in')}
</Button>
);
}
return <UserMenu user={user} logout={logout} actions={<LanguagePicker />} />;
};
@@ -1,4 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
@@ -23,7 +23,7 @@
* Select
**/
--c--components--forms-select--value-color--disabled: var(
--c--theme--colors--greyscale-400
--c--theme--colors--gray-400
);
/**
@@ -1,6 +1,6 @@
@import url('@openfun/cunningham-react/icons');
@import url('@openfun/cunningham-react/style');
@import url('@openfun/cunningham-react/fonts');
@import url('@gouvfr-lasuite/cunningham-react/icons');
@import url('@gouvfr-lasuite/cunningham-react/style');
@import url('@gouvfr-lasuite/cunningham-react/fonts');
@import url('./cunningham-tokens.css');
@import url('./cunningham-custom-tokens.css');
@import url('../assets/fonts/Marianne/Marianne-font.css');
@@ -9,7 +9,7 @@
.c__field,
.c__select,
.c__datagrid {
font-family: var(--c--theme--font--families--base);
font-family: var(--c--globals--font--families--base);
}
.c__field {
@@ -24,16 +24,16 @@
}
.labelled-box label {
color: var(--c--theme--colors--greyscale-500);
color: var(--c--contextuals--content--semantic--neutral--primary);
top: 1px;
}
.labelled-box--disabled label {
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
.labelled-box label.placeholder {
top: 12px;
top: 11px;
}
.c__field :not(.c__textarea__wrapper, div) .labelled-box label.placeholder {
@@ -48,11 +48,10 @@
.c__input__wrapper,
.c__textarea__wrapper {
width: 100%;
transition: all var(--c--theme--transitions--duration)
var(--c--theme--transitions--ease-out);
transition: all var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
gap: 0.5rem;
color: var(--c--theme--colors--greyscale-600);
background-color: var(--c--theme--colors--greyscale-050);
color: var(--c--globals--colors--gray-600);
}
@media screen and (width <= 768px) {
@@ -93,7 +92,7 @@
}
.c__input__wrapper--disabled .c__input {
color: var(--c--components--forms-input--value-color--disabled);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
.c__input__wrapper--error .c__input {
@@ -148,8 +147,8 @@ input:-webkit-autofill:focus {
}
.c__select__wrapper {
transition: all var(--c--theme--transitions--duration)
var(--c--theme--transitions--ease-out);
transition: all var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
min-height: var(--c--components--forms-select--height);
height: auto;
}
@@ -174,8 +173,8 @@ input:-webkit-autofill:focus {
}
.c__select__menu__item {
transition: all var(--c--theme--transitions--duration)
var(--c--theme--transitions--ease-out);
transition: all var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
}
.c__select--disabled .c__select__wrapper label,
@@ -239,17 +238,17 @@ input:-webkit-autofill:focus {
.c__datagrid__table__container > table tbody tr {
border: none;
border-top: 1px var(--c--theme--colors--greyscale-100) solid;
border-bottom: 1px var(--c--theme--colors--greyscale-100) solid;
border-top: 1px solid var(--c--contextuals--border--surface--primary);
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
}
.c__datagrid__table__container > table tbody tr:hover {
background-color: var(--c--theme--colors--greyscale-050);
background-color: var(--c--contextuals--background--surface--secondary);
}
.c__datagrid__table__container > table tbody {
background-color: var(--c--components--datagrid--body--background-color);
color: var(--c--theme--colors--greyscale-900);
color: var(--c--globals--colors--gray-900);
}
.c__datagrid__table__container > table {
@@ -286,7 +285,7 @@ input:-webkit-autofill:focus {
background-color: var(
--c--components--datagrid--pagination--background-color-active
);
color: var(--c--theme--colors--greyscale-800);
color: var(--c--globals--colors--gray-800);
}
.c__pagination__list .c__button--tertiary-text:disabled {
@@ -309,22 +308,22 @@ input:-webkit-autofill:focus {
}
.c__date-picker__wrapper {
transition: all var(--c--theme--transitions--duration)
var(--c--theme--transitions--ease-out);
transition: all var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
}
.c__date-picker:not(.c__date-picker--disabled):hover .c__date-picker__wrapper {
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
box-shadow: var(--c--globals--colors--primary-500) 0 0 0 2px;
}
.c__date-picker.c__date-picker--invalid:not(.c__date-picker--disabled):hover
.c__date-picker__wrapper {
box-shadow: var(--c--theme--colors--danger-300) 0 0 0 2px;
box-shadow: var(--c--globals--colors--danger-300) 0 0 0 2px;
}
.c__date-picker__wrapper button[aria-label='Clear date'],
.c__date-picker.c__date-picker--invalid .c__date-picker__wrapper * {
color: var(--c--theme--colors--danger-300);
color: var(--c--globals--colors--danger-300);
}
/**
@@ -345,7 +344,7 @@ input:-webkit-autofill:focus {
}
.c__checkbox.c__checkbox--disabled .c__field__text {
color: var(--c--theme--colors--greyscale-600);
color: var(--c--globals--colors--gray-600);
}
.c__switch.c__checkbox--disabled .c__switch__rail {
@@ -353,11 +352,11 @@ input:-webkit-autofill:focus {
}
.c__checkbox.c__checkbox--disabled .c__checkbox__label {
color: var(--c--theme--colors--greyscale-400);
color: var(--c--globals--colors--gray-400);
}
.c__radio input::before {
box-shadow: inset 1em 1em var(--c--theme--colors--primary-600);
box-shadow: inset 1em 1em var(--c--globals--colors--primary-600);
}
/**
@@ -551,8 +550,8 @@ input:-webkit-autofill:focus {
}
.c__button--with-icon--right {
padding: 0.7rem var(--c--theme--spacings--t) 0.7rem
var(--c--theme--spacings--s);
padding: 0.7rem var(--c--globals--spacings--t) 0.7rem
var(--c--globals--spacings--s);
}
/**
@@ -567,37 +566,28 @@ input:-webkit-autofill:focus {
box-shadow: none;
}
.c__modal__close button {
padding: 0;
font-size: 88px;
width: 28px !important;
height: 28px;
}
.c__modal__close button .material-icons {
padding: 0;
font-size: 24px;
color: var(--c--theme--colors--greyscale-600);
}
.c__modal__close .c__button {
padding: 0 !important;
}
.c__modal--full .c__modal__content {
overflow-y: auto;
}
.c__modal__content {
color: var(--c--theme--colors--greyscale-600) !important;
font-size: var(--c--globals--font--sizes--sm);
color: var(
--c--contextuals--content--semantic--neutral--secondary
) !important;
}
.c__modal__content__margin {
padding: var(--c--globals--spacings--md);
padding-bottom: 0;
}
.c__modal__title {
padding: var(--c--theme--spacings--md);
padding: var(--c--globals--spacings--md);
padding-bottom: 0;
margin-bottom: 0;
text-align: left;
font-size: 1.125rem;
font-size: var(--c--globals--font--sizes--lg) !important;
}
@media screen and (width <= 420px) {
@@ -614,7 +604,7 @@ input:-webkit-autofill:focus {
.c__modal__footer {
margin-top: 0;
background-color: transparent !important;
padding: var(--c--theme--spacings--md);
padding: var(--c--globals--spacings--md);
}
@media screen and (width <= 576px) {
@@ -638,8 +628,8 @@ input:-webkit-autofill:focus {
}
.c__modal__title {
font-size: var(--c--theme--font--sizes--xs);
padding: var(--c--theme--spacings--base);
font-size: var(--c--globals--font--sizes--xs);
padding: var(--c--globals--spacings--base);
margin-bottom: 0;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,17 +3,27 @@ import { create } from 'zustand';
import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default;
type ColorsTokens = Tokens['theme']['colors'];
type FontSizesTokens = Tokens['theme']['font']['sizes'];
type SpacingsTokens = Tokens['theme']['spacings'];
type ComponentTokens = Tokens['components'];
type Tokens = typeof tokens.themes.default &
Partial<(typeof tokens.themes)[keyof typeof tokens.themes]>;
type ColorsTokens = Tokens['globals']['colors'];
type FontSizesTokens = Tokens['globals']['font']['sizes'];
type SpacingsTokens = Tokens['globals']['spacings'];
type ComponentTokens = Partial<
| (Tokens['components'] & Tokens['globals']['components'])
| Record<string, unknown>
> &
Record<string, unknown>;
export const removeQuotes = (str: string) => {
return str.replace(/^['"]|['"]$/g, '');
};
export type Theme = keyof typeof tokens.themes;
interface AuthStore {
theme: string;
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
themeTokens: () => Partial<Tokens['globals']>;
colorsTokens: () => Partial<ColorsTokens>;
fontSizesTokens: () => Partial<FontSizesTokens>;
spacingsTokens: () => Partial<SpacingsTokens>;
@@ -29,11 +39,11 @@ export const useCunninghamTheme = create<AuthStore>((set, get) => {
return {
theme: 'dsfr',
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
themeTokens: () => currentTheme().globals,
colorsTokens: () => currentTheme().globals?.colors ?? {},
componentTokens: () => currentTheme().components,
spacingsTokens: () => currentTheme().theme.spacings,
fontSizesTokens: () => currentTheme().theme.font.sizes,
spacingsTokens: () => currentTheme().globals?.spacings ?? {},
fontSizesTokens: () => currentTheme().globals?.font?.sizes ?? {},
setTheme: (theme: Theme) => {
set({ theme });
},
@@ -13,7 +13,7 @@ const BlueStripe = styled.div`
position: absolute;
height: 2px;
width: 100%;
background: var(--c--theme--colors--primary-600);
background: var(--c--theme--colors--brand-600);
top: 0;
`;
@@ -82,7 +82,7 @@ export const Footer = () => {
gap:0.2rem;
transition: box-shadow 0.3s;
&:hover {
box-shadow: 0px 2px 0 0 var(--c--theme--colors--greyscale-text);
box-shadow: 0px 2px 0 0 var(--c--theme--colors--gray-text);
}
`}
>
@@ -98,7 +98,7 @@ export const Footer = () => {
$padding={{ top: 'tiny' }}
$css="
flex-wrap: wrap;
border-top: 1px solid var(--c--theme--colors--greyscale-200);
border-top: 1px solid var(--c--theme--colors--gray-200);
column-gap: 1rem;
row-gap: .5rem;
"
@@ -1,116 +1,84 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { default as IconRegie } from '@/assets/logo-regie.svg?url';
import { Icon, StyledLink, Text } from '@/components/';
import { ButtonLogin } from '@/core/auth';
import { StyledLink } from '@/components/';
import { UserInfo } from '@/core/auth/UserInfo';
import { useCunninghamTheme } from '@/cunningham';
import { LanguagePicker } from '@/features/language';
import { useLeftPanelStore } from '@/features/left-panel';
import { TogglePanelButton } from '@/features/left-panel/components/TogglePanelButton';
import { LaGaufre } from './LaGaufre';
export const HEADER_HEIGHT = '52px';
export const Header = () => {
const { t } = useTranslation();
const theme = useCunninghamTheme();
const { isPanelOpen, togglePanel } = useLeftPanelStore();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const { isPanelOpen } = useLeftPanelStore();
return (
<header
className=""
style={{
display: 'flex',
position: 'fixed',
top: 0,
position: 'sticky',
top: '0px',
left: 0,
right: 0,
zIndex: 1000,
flexDirection: 'row',
padding: '10px',
alignItems: 'center',
justifyContent: 'space-between',
height: 'var(--header-height)',
minHeight: 'var(--header-height)',
padding: `0 ${spacings['base']}`,
backgroundColor: colors['greyscale-000'],
borderBottom: `1px solid ${colors['greyscale-200']}`,
background:
'linear-gradient(180deg, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0.00) 100%)',
backdropFilter: 'blur(1px)',
}}
>
<Button
className="md:hidden"
size="medium"
onClick={() => togglePanel()}
aria-label={t('Open the header menu')}
color="tertiary-text"
icon={
<Icon
$variation="800"
$theme="primary"
iconName={isPanelOpen ? 'close' : 'menu'}
/>
}
/>
<StyledLink href="/">
<div
style={{
display: 'flex',
alignItems: 'center',
borderRadius: '8px',
backgroundColor:
'var(--c--contextuals--background--surface--primary)',
backdropFilter: 'blur(10px)',
border: '1px solid var(--c--contextuals--border--surface--primary)',
padding: '4px',
flexDirection: 'row',
boxShadow: '0 2px 4px 0 rgba(0, 0, 0, 0.05)',
}}
>
<TogglePanelButton />
<div
style={{
display: 'flex',
alignItems: 'center',
gap: spacings['3xs'],
overflow: 'hidden',
maxWidth: isPanelOpen ? '0' : '120px',
opacity: isPanelOpen ? '0' : '1',
transition: 'max-width 0.3s ease, opacity 0.2s ease 0.3s',
}}
>
<Image priority src={IconRegie} alt={t('La régie Logo')} width={25} />
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: spacings['xs'],
}}
>
<Text
as="h2"
style={{ color: '#000091', zIndex: 1, fontSize: '1.30rem' }}
>
{t('La Régie')}
</Text>
<Text
style={{
padding: '1px 8px',
fontSize: '11px',
fontWeight: 'bold',
color: colors['primary-500'],
borderRadius: '12px',
backgroundColor: colors['primary-200'],
}}
>
BETA
</Text>
</div>
</div>
</StyledLink>
<div className="md:hidden">
<div
style={{ display: 'flex', flexDirection: 'row', gap: spacings['sm'] }}
>
<LaGaufre />
<StyledLink href="/">
<div style={{ display: 'flex', alignItems: 'center' }}>
<Image priority src={IconRegie} alt="Régie Logo" height={34} />
</div>
</StyledLink>
</div>
</div>
<div className="hidden md:block">
<div className="">
<div
style={{
display: 'flex',
display: 'inline-flex',
alignItems: 'center',
gap: spacings['sm'],
borderRadius: '8px',
backgroundColor:
'var(--c--contextuals--background--surface--primary)',
border: '1px solid var(--c--contextuals--border--surface--primary)',
padding: '4px',
gap: '4px',
flexDirection: 'row',
boxShadow: '0 2px 4px 0 rgba(0, 0, 0, 0.05)',
}}
>
<UserInfo />
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</div>
</div>
@@ -1,25 +1,114 @@
import { Gaufre } from '@gouvfr-lasuite/integration';
import '@gouvfr-lasuite/integration/dist/css/gaufre.css';
// import { Gaufre } from '@gouvfr-lasuite/integration';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Script from 'next/script';
import React from 'react';
import { createGlobalStyle } from 'styled-components';
import { useEffect } from 'react';
const GaufreStyle = createGlobalStyle`
.lasuite-gaufre-btn{
box-shadow: inset 0 0 0 0 !important;
declare global {
interface Window {
_lasuite_widget?: unknown[];
}
`;
}
export const LaGaufre = () => {
useEffect(() => {
const wrapper = document.querySelector('[data-gaufre-button-wrapper]');
const button = wrapper?.querySelector('button') as HTMLButtonElement;
if (button && !button.id) {
button.id = 'gaufre_button';
button.setAttribute('aria-expanded', 'false');
}
const applyZIndex = () => {
const shadowHost = document.querySelector(
'#lasuite-widget-lagaufre-shadow',
);
const wrapper = shadowHost?.shadowRoot?.querySelector(
'#wrapper',
) as HTMLElement;
if (wrapper) {
wrapper.style.zIndex = '1000000000';
}
};
setTimeout(applyZIndex, 500);
}, []);
return (
<>
<div data-gaufre-button-wrapper>
<Button
variant="tertiary"
className="!w-10 !h-10 !p-0 !min-w-0"
aria-label="Les services de LaSuite"
aria-expanded="false"
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<path
fill="var(--c--contextuals--content--semantic--brand--tertiary)"
id="square"
d="M2.7959 0.5C3.26483 0.5 3.49956 0.49985 3.68848 0.564453C4.03934 0.684581 4.31542 0.960658 4.43555 1.31152C4.50015 1.50044 4.5 1.73517 4.5 2.2041V2.7959C4.5 3.26483 4.50015 3.49956 4.43555 3.68848C4.31542 4.03934 4.03934 4.31542 3.68848 4.43555C3.49956 4.50015 3.26483 4.5 2.7959 4.5H2.2041C1.73517 4.5 1.50044 4.50015 1.31152 4.43555C0.960658 4.31542 0.684581 4.03934 0.564453 3.68848C0.49985 3.49956 0.5 3.26483 0.5 2.7959V2.2041C0.5 1.73517 0.49985 1.50044 0.564453 1.31152C0.684581 0.960658 0.960658 0.684581 1.31152 0.564453C1.50044 0.49985 1.73517 0.5 2.2041 0.5H2.7959Z"
/>
</defs>
<use href="#square" transform="translate(0, 0)" />
<use href="#square" transform="translate(6.5, 0)" />
<use href="#square" transform="translate(13, 0)" />
<use href="#square" transform="translate(0, 6.5)" />
<use href="#square" transform="translate(6.5, 6.5)" />
<use href="#square" transform="translate(13, 6.5)" />
<use href="#square" transform="translate(0, 13)" />
<use href="#square" transform="translate(6.5, 13)" />
<use href="#square" transform="translate(13, 13)" />
</svg>
</Button>
</div>
<Script
src="https://integration.lasuite.numerique.gouv.fr/api/v1/gaufre.js"
src="https://static.suite.anct.gouv.fr/widgets/lagaufre.js"
strategy="lazyOnload"
id="lasuite-gaufre-script"
onLoad={() => {
const button = document.getElementById('gaufre_button');
if (button) {
globalThis.window._lasuite_widget =
globalThis.window._lasuite_widget || [];
globalThis.window._lasuite_widget.push([
'lagaufre',
'init',
{
api: 'https://lasuite.numerique.gouv.fr/api/services',
label: 'Services de la Suite numérique',
closeLabel: 'Fermer le menu',
headerLabel: 'À propos',
backgroundColor: '#fff',
background:
'linear-gradient(#f1f2fd, rgba(255, 255, 255, 1) 20.54%, #FFF 0%',
headerLogo:
'https://lasuite.numerique.gouv.fr/assets/lasuite.svg',
headerUrl: 'https://lasuite.numerique.gouv.fr',
loadingText: 'Chargement…',
newWindowLabelSuffix: ' (nouvelle fenêtre)',
fontFamily: 'Marianne',
buttonElement: button,
viewMoreLabel: 'Voir plus',
viewLessLabel: 'Voir moins',
position: () => {
return {
backgroundColor: '#fff',
position: 'fixed',
top: 65,
right: 20,
};
},
},
]);
}
}}
/>
<GaufreStyle />
<Gaufre variant="small" />
</>
);
};
@@ -1,63 +1,29 @@
import { Select } from '@openfun/cunningham-react';
import { LanguagePicker as LanguagePickerUi } from '@gouvfr-lasuite/ui-kit';
import { Settings } from 'luxon';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
flex-shrink: 0;
width: auto;
.c__select__wrapper {
min-height: 2rem;
height: auto;
border-color: transparent;
padding: 0 0.15rem 0 0.45rem;
border-radius: 1px;
.labelled-box .labelled-box__children {
padding-right: 2rem;
.c_select__render .typo-text {
${({ $isSmall }) => $isSmall && `display: none;`}
}
}
&:hover {
box-shadow: none !important;
}
}
`;
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { i18n } = useTranslation();
const { preload: languages } = i18n.options;
Settings.defaultLocale = i18n.language;
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
value: lang,
label: lang,
label: LANGUAGES_ALLOWED[lang],
render: () => (
<Box
className="c_select__render"
$direction="row"
$gap="0.7rem"
$align="center"
$css="text-transform: uppercase;"
>
<Text
$isMaterialIcon
$size="1rem"
$theme="primary"
$weight="bold"
$variation="800"
>
translate
</Text>
<Text $theme="primary" $weight="500" $variation="800">
<Text $theme="brand" $weight="500" $variation="800">
{LANGUAGES_ALLOWED[lang]}
</Text>
</Box>
@@ -71,33 +37,33 @@ export const LanguagePicker = () => {
}
}, [i18n.language]);
/**
* @description prevent select div to receive focus on keyboard navigation so the focus goes directly to inner button
* @see https://github.com/suitenumerique/people/pull/379
*/
useEffect(() => {
if (!document) {
return;
}
document
.querySelector('.c__select-language-picker .c__select__wrapper')
?.setAttribute('tabindex', '-1');
}, []);
return (
<SelectStyled
label={t('Language')}
showLabelWhenSelected={false}
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg"
options={optionsPicker}
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
<LanguagePickerUi
languages={optionsPicker}
size="small"
onChange={(selected) => {
type LanguageOption = { value?: string };
let code: string | undefined;
if (typeof selected === 'string') {
code = selected;
} else if (
typeof selected === 'object' &&
selected !== null &&
typeof (selected as LanguageOption).value === 'string'
) {
code = (selected as LanguageOption).value;
}
if (!code) {
return;
}
i18n.changeLanguage(code).catch((err) => {
console.error('Error changing language', err);
});
}}
compact
/>
);
};
@@ -1,16 +1,15 @@
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { useCallback, useEffect } from 'react';
import { createGlobalStyle } from 'styled-components';
import { default as IconRegie } from '@/assets/logo-regie.svg?url';
import { StyledLink } from '@/components/';
import { SeparatedSection } from '@/components/separators';
import { ButtonLogin } from '@/core';
import { UserInfo } from '@/core/auth/UserInfo';
import { LanguagePicker } from '@/features/language';
import { useLeftPanelStore } from '../stores';
import { LeftPanelContent } from './LeftPanelContent';
import { LeftPanelHeader } from './LeftPanelHeader';
import { LeftPanelItems } from './LeftPanelItems';
const MobileLeftPanelStyle = createGlobalStyle`
@@ -21,11 +20,15 @@ const MobileLeftPanelStyle = createGlobalStyle`
const LeftPanelStyle = createGlobalStyle`
.LeftPanel {
height: calc(100vh - var(--header-height));
position: sticky;
top: 0;
padding: 12px;
height: 100vh;
width: 300px;
min-width: 300px;
overflow: hidden;
border-right: 1px solid var(--c--theme--colors--greyscale-200']};
background-color: var(--c--contextuals--background--surface--tertiary);
border-right: 1px solid var(--c--contextuals--border--surface--primary);
}
`;
@@ -34,29 +37,63 @@ export const LeftPanel = () => {
const pathname = usePathname();
const toggle = useCallback(() => {
const closePanel = useCallback(() => {
togglePanel(false);
}, [togglePanel]);
useEffect(() => {
toggle();
}, [pathname, toggle]);
if (
typeof globalThis.window !== 'undefined' &&
globalThis.window.matchMedia('(min-width: 768px)').matches
) {
togglePanel(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (
typeof globalThis.window !== 'undefined' &&
!globalThis.window.matchMedia('(min-width: 768px)').matches
) {
closePanel();
}
}, [pathname, closePanel]);
return (
<>
<div className="hidden md:block">
<div
className="hidden md:block"
style={{
width: isPanelOpen ? undefined : 0,
minWidth: isPanelOpen ? undefined : 0,
overflow: 'hidden',
transition: 'min-width 0.2s ease, width 0.2s ease',
}}
>
<LeftPanelStyle />
<div className="LeftPanel">
<div className="LeftPanel" style={{ minWidth: isPanelOpen ? 300 : 0 }}>
<div
style={{
flex: '0 0 auto',
}}
></div>
<StyledLink href="/">
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '1rem',
}}
>
<Image priority src={IconRegie} alt="Régie" height={40} />
</div>
</StyledLink>
<menu
style={{
margin: '0',
gap: 'var(--c--theme--spacings--xs)',
padding: '0 10px',
padding: '0',
}}
>
<LeftPanelItems />
@@ -71,11 +108,18 @@ export const LeftPanel = () => {
style={{
zIndex: '999',
width: '100vw',
height: 'calc(100dvh - var(--header-height))',
borderRight: '1px solid var(--c--theme--colors--greyscale-200)',
height: '100vh',
position: 'fixed',
inset: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
padding: '12px',
backgroundColor:
'var(--c--contextuals--background--surface--tertiary)',
transform: `translateX(${isPanelOpen ? '0' : '-100dvw'})`,
backgroundColor: 'var(--c--theme--colors--greyscale-000)',
transition: 'transform 0.2s ease-out',
overflowY: 'auto',
}}
className="md:hidden"
>
@@ -83,13 +127,17 @@ export const LeftPanel = () => {
data-testid="left-panel-mobile"
style={{
width: '100%',
padding: '0 10px',
justifyContent: 'center',
marginTop: '60px',
justifyContent: 'flex-start',
gap: 'var(--c--theme--spacings--base)',
}}
className="md:hidden"
>
<LeftPanelHeader />
<StyledLink href="/">
<div>
<Image priority src={IconRegie} alt="Régie" height={40} />
</div>
</StyledLink>
<LeftPanelItems />
<SeparatedSection showSeparator={false}>
<div
@@ -99,11 +147,7 @@ export const LeftPanel = () => {
flexDirection: 'column',
gap: 'var(--c--theme--spacings--sm)',
}}
>
<UserInfo />
<ButtonLogin />
<LanguagePicker />
</div>
></div>
</SeparatedSection>
</div>
</div>
@@ -1,57 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { PropsWithChildren } from 'react';
import { Icon, SeparatedSection } from '@/components';
// import { useAuthStore } from '@/core/auth/useAuthStore';
import { useLeftPanelStore } from '../stores';
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const router = useRouter();
// const auth = useAuthStore();
const { togglePanel } = useLeftPanelStore();
const goToHome = () => {
router.push('/');
togglePanel();
};
// const createNewDoc = () => {
// createDoc({ title: t('Untitled document') });
// };
return (
<>
<div>
<SeparatedSection>
<div>
<Button
onClick={goToHome}
size="medium"
color="tertiary-text"
icon={<Icon $variation="800" $theme="primary" iconName="house" />}
/>
{/* {auth.authenticated && (
<Button
onClick={searchModal.open}
size="medium"
color="tertiary-text"
icon={
<Icon $variation="800" $theme="primary" iconName="search" />
}
/>
)}*/}
</div>
{/* {auth.authenticated && (
<Button onClick={createNewDoc}>{t('New doc')}</Button>
)}*/}
</SeparatedSection>
{children}
</div>
{/* {searchModal.isOpen && (
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
)}*/}
</>
);
};
@@ -1,28 +1,23 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { usePathname, useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { Icon, Text } from '@/components';
import { SeparatedSection } from '@/components/separators';
import { useAuthStore } from '@/core/auth';
import { useCunninghamTheme } from '@/cunningham';
import { useLeftPanelStore } from '@/features/left-panel';
export const LeftPanelItems = () => {
const { t } = useTranslation();
const { closePanel } = useLeftPanelStore();
const { colorsTokens } = useCunninghamTheme();
const { userData } = useAuthStore();
const colors = colorsTokens();
const router = useRouter();
const pathname = usePathname();
const defaultQueries = [
{
icon: 'group',
label: t('Teams'),
label: t('Groups'),
targetQuery: '/teams',
abilities: userData?.abilities?.teams.can_view,
},
@@ -36,7 +31,12 @@ export const LeftPanelItems = () => {
const onSelectQuery = (query: string) => {
router.push(query);
closePanel();
if (
typeof globalThis.window !== 'undefined' &&
!globalThis.window.matchMedia('(min-width: 768px)').matches
) {
closePanel();
}
};
return (
@@ -47,7 +47,7 @@ export const LeftPanelItems = () => {
gap: 'var(--c--theme--spacings--2xs)',
}}
>
{defaultQueries.map((query, index) => {
{defaultQueries.map((query) => {
if (!query.abilities) {
return null;
}
@@ -57,34 +57,32 @@ export const LeftPanelItems = () => {
pathname.startsWith(`${query.targetQuery}/`);
return (
<SeparatedSection
<Button
color="neutral"
variant="tertiary"
style={{
backgroundColor: isActive
? 'var(--c--contextuals--background--semantic--overlay--primary)'
: '',
fontWeight: isActive ? 'bold' : '500',
marginBottom: '4px',
}}
key={query.label}
showSeparator={defaultQueries.length - 1 > index}
>
<Button
data-testid={`${query.label} button`}
aria-label={`${query.label} button`}
onClick={() => onSelectQuery(query.targetQuery)}
style={{
gap: 'var(--c--theme--spacings--xs)',
margin: '10px 0',
cursor: 'pointer',
border: 'none',
backgroundColor: isActive
? colors['greyscale-100']
: colors['greyscale-000'],
fontWeight: isActive ? 700 : undefined,
}}
>
data-testid={`${query.label} button`}
aria-label={`${query.label} button`}
aria-current={isActive ? 'page' : undefined}
onClick={() => onSelectQuery(query.targetQuery)}
icon={
<Icon
$variation={isActive ? '1000' : '700'}
$width="16"
$height="16"
$theme="neutral"
iconName={query.icon}
/>
<Text $variation={isActive ? '1000' : '700'} $size="sm">
{query.label}
</Text>
</Button>
</SeparatedSection>
}
>
<Text $size="sm">{query.label}</Text>
</Button>
);
})}
</div>
@@ -0,0 +1,21 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import IconPanelMenu from '@/assets/icons/icon-panel-menu.svg';
import { useLeftPanelStore } from '@/features/left-panel';
export const TogglePanelButton = () => {
const { t } = useTranslation();
const { isPanelOpen, togglePanel } = useLeftPanelStore();
return (
<Button
size="medium"
onClick={() => togglePanel()}
aria-label={isPanelOpen ? t('Close the menu') : t('Open the menu')}
color="neutral"
variant="tertiary"
icon={<IconPanelMenu width={24} height={24} aria-hidden />}
/>
);
};
@@ -1 +1,2 @@
export * from './LeftPanel';
export * from './TogglePanelButton';
@@ -1,4 +1,4 @@
import { Input } from '@openfun/cunningham-react';
import { Input } from '@gouvfr-lasuite/cunningham-react';
interface InputUserEmailProps {
label: string;
@@ -1,4 +1,4 @@
import { Input } from '@openfun/cunningham-react';
import { Input } from '@gouvfr-lasuite/cunningham-react';
interface InputUserEmailProps {
label: string;
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { Box, Text } from '@/components';
import { InputUserEmail, InputUserPassword } from '@/features/login';
@@ -82,7 +82,7 @@ export const LoginForm = ({
)}
<Box $padding="tiny">
<Button color="primary" type="submit" fullWidth>
<Button color="brand" type="submit" fullWidth>
{labelSignIn}
</Button>
</Box>
@@ -1,5 +1,7 @@
export * from './useMailDomainAccesses';
export * from './useInvitationMailDomainAccesses';
export * from './useUpdateMailDomainAccess';
export * from './useCreateMailDomainAccess';
export * from './useDeleteMailDomainAccess';
export * from './useDeleteMailDomainInvitation';
export * from './useCreateInvitation';
@@ -1,10 +1,16 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import {
KEY_LIST_MAIL_DOMAIN,
KEY_MAIL_DOMAIN,
MailDomain,
Role,
} from '@/features/mail-domains/domains';
import { Invitation, OptionType } from '@/features/teams/member-add/types';
import { MailDomain, Role } from '../../domains';
import { KEY_LIST_INVITATION_DOMAIN_ACCESSES } from './useInvitationMailDomainAccesses';
interface CreateInvitationParams {
email: User['email'];
@@ -42,7 +48,20 @@ export const createInvitation = async ({
};
export function useCreateInvitation() {
const queryClient = useQueryClient();
return useMutation<Invitation, APIError, CreateInvitationParams>({
mutationFn: createInvitation,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_INVITATION_DOMAIN_ACCESSES],
});
void queryClient.invalidateQueries({
queryKey: [KEY_MAIL_DOMAIN],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN],
});
},
});
}
@@ -0,0 +1,89 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import {
KEY_LIST_MAIL_DOMAIN,
KEY_MAIL_DOMAIN,
} from '@/features/mail-domains/domains';
import { KEY_LIST_INVITATION_DOMAIN_ACCESSES } from './useInvitationMailDomainAccesses';
interface DeleteMailDomainInvitationProps {
slug: string;
invitationId: string;
}
export const deleteMailDomainInvitation = async ({
slug,
invitationId,
}: DeleteMailDomainInvitationProps): Promise<void> => {
const response = await fetchAPI(
`mail-domains/${slug}/invitations/${invitationId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the invitation',
await errorCauses(response),
);
}
};
type UseDeleteMailDomainInvitationOptions = UseMutationOptions<
void,
APIError,
DeleteMailDomainInvitationProps
>;
export const useDeleteMailDomainInvitation = (
options?: UseDeleteMailDomainInvitationOptions,
) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<void, APIError, DeleteMailDomainInvitationProps>({
mutationFn: deleteMailDomainInvitation,
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_INVITATION_DOMAIN_ACCESSES],
});
void queryClient.invalidateQueries({
queryKey: [KEY_MAIL_DOMAIN],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN],
});
if (optionsOnSuccess) {
(
optionsOnSuccess as unknown as (
data: void,
variables: DeleteMailDomainInvitationProps,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (optionsOnError) {
(
optionsOnError as unknown as (
error: APIError,
variables: DeleteMailDomainInvitationProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};

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