Compare commits

..

25 Commits

Author SHA1 Message Date
Manuel Raynaud
7024a23196 🔧(compose) replace minio by rustfs
Minio is no longer maintained, its git repository is archived. We need
to replace it. We decided to choose rustfs
7aac2a2c5b
https://github.com/rustfs/rustfs
2026-02-18 09:52:15 +01:00
Manuel Raynaud
fa0c3847e4 🐛(hub) fix mistake in docker user
Remove :-1000 after docker user in github workflows
2026-02-16 11:43:24 +01:00
Manuel Raynaud
49871c45b1 🐛(helm) reverse liveness and readiness for backend deployment
The liveness and readiness are reversed. The liveness was using the
heartbeat process that is cheking all django checks and the database
connection.
2026-02-13 09:51:41 +01:00
Manuel Raynaud
2cc0d71b89 🐛(helm) use celery resources instead of backend resources
In the celery deployment, the backend resources are used and not the one
from the celery values.
2026-02-13 09:31:51 +01:00
dtinth-ampere-devbox
33785440c6 👷(CI) add GHCR workflow for forked repo testing
Add workflow_dispatch-triggered workflow that builds and pushes images
to GitHub Container Registry (ghcr.io). This allows forked repositories
to build their own images for testing without requiring Docker Hub
credentials.

Images are tagged with branch names, semver tags, and commit SHA for
easy testing of specific builds.

Signed-off-by: dtinth on MBP M1 <dtinth@spacet.me>
2026-02-12 14:08:24 +01:00
Anthony LC
75c7811755 💄(frontend) align colors with theme from figma
Some colors were not exactly the same as the ones
in figma, this commit aligns them with the theme
tokens.
2026-02-12 12:53:51 +01:00
Anthony LC
f4cb66d6b6 💄(frontend) Title header optional
On the DSFR instance the title will not
be displayed anymore in favor of
a icon that include the title.
So we make the title optional, it
will be configurable from the
theme configuration.
2026-02-12 12:53:30 +01:00
Anthony LC
57dc56f83e 🎨(frontend) improve overriding from configuration theme
We were partially overriding the frontend with the
cunningham theme meaning at build time. We stop to
do this way to do it only from the configuration
theme. This way it will be easier to maintain and
to update.
We improve as well the typing with more global types
like Image type from logo and icons, and HTMLLinkElement
type for the favicon, meaning you can really
override compoments from the configuration theme.
2026-02-12 12:53:30 +01:00
Cyril
de1a0e4a73 ️(frontend) prevent dates from being focusable
Keep date links clickable with mouse while removing keyboard focus.
2026-02-12 11:40:29 +01:00
BEY Quentin
17cb213ecd 🚸(oidc) ignore case when fallback on email
Some identity providers might change the case, but in our products we
don't consider case variation to be consider as different email
addresses.

Next step would be to normalize the DB value of email to be lower-case.
2026-02-11 18:48:51 +00:00
Sylvain Boissel
3ab0a47c3a (backend) manage reconciliation requests for user accounts (#1878)
For now, the reconciliation requests are imported through CSV in the
Django admin, which sends confirmation email to both addresses. When
both are checked, the actual reconciliation is processed, and all
user-related content is updated.

## Purpose

Fix #1616 // Replaces #1708

For now, the reconciliation requests are imported through CSV in the
Django admin, which sends confirmation email to both addresses. When
both are checked, the actual reconciliation is processed, and all
user-related content is updated.


## Proposal
- [x] New `UserReconciliationCsvImport` model to manage the import of
reconciliation requests through a task
(`user_reconciliation_csv_import_job`)
- [x] New `UserReconciliation` model to store the user reconciliation
requests themselves (a row = a `active_user`/`inactive_user` pair)
  - [x] On save, a confirmation email is sent to the users
- [x] A `process_reconciliation` admin action process the action on the
requested entries, if both emails have been checked.
- [x] Bulk update the `DocumentAccess` items, while managing the case
where both users have access to the document (keeping the higher role)
- [x] Bulk update the `LinkTrace` items, while managing the case where
both users have link traces to the document
- [x] Bulk update the `DocumentFavorite` items, while managing the case
where both users have put the document in their favorites
- [x] Bulk update the comment system items (`Thread`, `Comment` and
`Reaction` items)
  - [x] Bulk update the `is_active` status on both users
- [x] New `USER_RECONCILIATION_FORM_URL` env variable for the "make a
new request" URL in an email.
- [x] Write unit tests
- [x] Remove the unused `email_user()` method on `User`, replaced with
`send_email()` similar to the one on the `Document` model


## Demo page reconciliation success

<img width="1149" height="746" alt="image"
src="https://github.com/user-attachments/assets/09ba2b38-7af3-41fa-a64f-ce3c4fd8548d"
/>

---------

Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
2026-02-11 18:09:20 +00:00
Sylvain Boissel
685464f2d7 🚸(backend) sort user search results by proximity with the active user (#1802)
## Purpose
Allows a user to find more easily the other users they search, with the
following order of priority:
- users they already share documents with (more recent first)
- users that share the same full email domain
- ~~users that share the same partial email domain (last two parts)~~
- ~~other users~~

Edit: We need to ilter out other users in order to not reveal email
addresses from members of other organisations. It's still possible to
invite them by email.

Solves #1521

## Proposal
- [x] Add a new function in `core/utils.py`:
`users_sharing_documents_with()`
- [x] Use it as a key to sort the results of a basic user search
- [x] Filter user results to avoid reveal of users (and email addresses)
of other orgs or that have not been interacted with.
- [x] User research through "full" email address (contains the '@') is
left unaffected.

---------

Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
2026-02-11 18:51:45 +01:00
virgile-dev
9af540de35 📝(readme) replace demo link (#1875)
so that the sandbox is not a public gov one

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2026-02-11 16:48:24 +00:00
Manuel Raynaud
6c43ecc324 🔧(docker) change mime.types url in Dockerfile
Change mime.types url in Dockerfile
2026-02-11 14:17:32 +00:00
renovate[bot]
607bae0022 ⬆️(dependencies) update axios to v1.13.5 [SECURITY] 2026-02-10 09:29:05 +00:00
Anthony LC
1d8b730715 (e2e) add threshold in regression test
When comparing PDF screenshots, we can have some
minor differences due to the different environments
(OS, fonts, etc.).
To avoid false positives in our regression
tests, we can set a threshold for the number of
different pixels allowed before considering the
test as failed.
If the test fails we will now report the PDF
and the differences to identify quickly
what are the regressions.
2026-02-09 16:17:05 +01:00
Anthony LC
d02c6250c9 🔒️(frontend) harden security check on url
We harden the security check on url to prevent attacks.
2026-02-09 16:17:05 +01:00
Anthony LC
b8c1504e7a ♻️(export) change pdf block from embed to iframe
When trying to print with a embed PDF the
browser's print dialog stays blocked and the user
can't print the document. Changing the PDF block
to use an iframe instead of an embed resolves
this issue.
2026-02-09 16:17:05 +01:00
Anthony LC
18edcf8537 🚨(ci) limit print check to backend
We added a feature to print documents directly
from the browser. The function is called
`window.print()`, this name collides with Python's
`print()` function. To avoid false positives in our
CI when checking for print statements, we limit
the search to only the backend code.
2026-02-09 16:17:05 +01:00
Anthony LC
5d8741a70a (frontend) print a doc with native browser
We can now print a doc with the native browser
print dialog.
This feature uses the browser's built-in print
capabilities to generate a print preview and
allows users to print directly from the application.
It has as well a powerfull print to PDF feature
that leverages the browser's PDF generation
capabilities for better compatibility and
quality.

Co-authored-by: AntoLC <anthony.le-courric@mail.numerique.gouv.fr>
Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-02-09 16:17:04 +01:00
Cyril
48df68195a ️(frontend) focus docs list title after filter navigation
Explain focus shift to match skip-to-content behavior.

hook useRouteChangeCompleteFocus

Positionne the focus on the first target or main element after a route change.
2026-02-05 11:13:30 +01:00
buildwithricky
7cf42e6404 🐛(frontend) fix doc timestamp display
Implemented the logic to show 'Just now' instead
of '0 seconds ago' when the difference is under
one second.

Signed-off-by: buildwithricky <nwakezepatrick@gmail.com>
2026-02-04 09:34:21 +01:00
Manuel Raynaud
9903bd73e2 ️(actions) enable trivy scan on backend image
The trivy was disabled because protobuf library was blocking the release
process. We can now enable it again, a new release of protobuf is
available.
2026-02-03 16:45:13 +00:00
Anthony LC
44b38347c4 🐛(frontend) fix broadcast store sync
When going from one subdoc to another by example,
the broadcast store could have difficulty to resync.
This commit ensures that the broadcast store
cleans up and resets its state when rerendering.
It will stop as well triggering the action for
the current user avoiding potential unecessary
requests.
2026-02-03 14:25:35 +01:00
Thai Pangsakulyanont
709076067b 🐛(backend) add AWS_S3_SIGNATURE_VERSION environment variable support
Add support for the `AWS_S3_SIGNATURE_VERSION` environment variable to
allow configuring S3 signature version for compatibility with
S3-compatible storage services like Linode Object Storage.

Fixes #1788

Signed-off-by: dtinth on MBP M1 <dtinth@spacet.me>
2026-02-02 10:47:14 +00:00
119 changed files with 21410 additions and 21862 deletions

View File

@@ -5,13 +5,13 @@ on:
workflow_dispatch:
push:
branches:
- 'main'
- "main"
tags:
- 'v*'
- "v*"
pull_request:
branches:
- 'main'
- 'ci/trivy-fails'
- "main"
- "ci/trivy-fails"
env:
DOCKER_USER: 1001:127
@@ -20,40 +20,34 @@ jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
- name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-backend
-
name: Login to DockerHub
- name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
-
name: Build and push
- name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: "--target backend-production -f Dockerfile"
docker-image-name: "docker.io/lasuite/impress-backend:${{ github.sha }}"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
build-args: DOCKER_USER=${{ env.DOCKER_USER }}
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
@@ -62,43 +56,37 @@ jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
- name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-frontend
-
name: Login to DockerHub
- name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
- name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
docker-build-args: "-f src/frontend/Dockerfile --target frontend-production"
docker-image-name: "docker.io/lasuite/impress-frontend:${{ github.sha }}"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
DOCKER_USER=${{ env.DOCKER_USER }}
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
@@ -107,27 +95,22 @@ jobs:
build-and-push-y-provider:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
- name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-y-provider
-
name: Login to DockerHub
- name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
- name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
-
name: Build and push
docker-build-args: "-f src/frontend/servers/y-provider/Dockerfile --target y-provider"
docker-image-name: "docker.io/lasuite/impress-y-provider:${{ github.sha }}"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
@@ -137,8 +120,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af

142
.github/workflows/ghcr.yml vendored Normal file
View File

@@ -0,0 +1,142 @@
name: Build and Push to GHCR
run-name: Build and Push to GHCR
on:
workflow_dispatch:
push:
branches:
- "main"
tags:
- "v*"
env:
DOCKER_USER: 1001:127
REGISTRY: ghcr.io
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/backend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-frontend:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/frontend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}
PUBLISH_AS_MIT=false
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-y-provider:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/y-provider
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f

View File

@@ -27,7 +27,7 @@ jobs:
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- src/backend ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
@@ -202,7 +202,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages

View File

@@ -6,9 +6,29 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) Can print a doc #1832
- ✨(backend) manage reconciliation requests for user accounts #1878
- 👷(CI) add GHCR workflow for forked repo testing #1851
### Changed
- ♿️(frontend) prevent dates from being focusable #1855
- ♿️(frontend) Focus main container after navigation #1854
- 💄(frontend) align colors and logo with ui-kit v2 #1869
- 🚸(backend) sort user search results by proximity with the active user #1802
- 🚸(oidc) ignore case when fallback on email #1880
### Fixed
- 🐛(frontend) fix broadcast store sync #1846
- 🐛(helm) use celery resources instead of backend resources
- 🐛(helm) reverse liveness and readiness for backend deployment
## [v4.5.0] - 2026-01-28
### Added
### Added
- ✨(frontend) integrate configurable Waffle #1795
- ✨ Import of documents #1609

View File

@@ -36,7 +36,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
yarn build
# ---- static link collector ----
@@ -58,7 +58,7 @@ WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
@@ -81,7 +81,7 @@ RUN apk add --no-cache \
pango \
shared-mime-info
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
RUN wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -98,9 +98,9 @@ COPY --from=back-builder /install /usr/local
# when python is upgraded and the path to the certificate changes.
# The space between print and the ( is intended otherwise the git lint is failing
RUN mkdir /cert && \
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
@@ -109,7 +109,7 @@ WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
@@ -138,7 +138,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services
# name) & port
ENV DB_HOST=postgresql \
DB_PORT=5432
DB_PORT=5432
# Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

View File

@@ -72,7 +72,7 @@ For some advanced features (ex: Export as PDF) Docs relies on XL packages from B
### Test it
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
You can test Docs on your browser by visiting this [demo document](https://docs.la-suite.eu/docs/9137bbb5-3e8a-4ff7-8a36-fcc4e8bd57f4/)
### Run Docs locally

View File

@@ -22,34 +22,44 @@ services:
ports:
- "1081:1080"
minio:
rustfs:
user: ${DOCKER_USER:-1000}
image: minio/minio
image: rustfs/rustfs:latest
environment:
- MINIO_ROOT_USER=impress
- MINIO_ROOT_PASSWORD=password
- RUSTFS_ACCESS_KEY=rustfsadmin
- RUSTFS_SECRET_KEY=rustfsadmin
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ADDRESS=0.0.0.0:9000
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
- RUSTFS_CORS_ALLOWED_ORIGINS=*
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
ports:
- '9000:9000'
- '9001:9001'
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
test:
[
"CMD",
"sh",
"-c",
"curl -f http://127.0.0.1:9000/health && curl -f http://127.0.0.1:9001/rustfs/console/health",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
minio:
rustfs:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
/usr/bin/mc alias set impress http://rustfs:9000 rustfsadmin rustfsadmin && \
/usr/bin/mc mb impress/impress-media-storage && \
/usr/bin/mc version enable impress/impress-media-storage && \
exit 0;"
@@ -81,16 +91,16 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
celery-dev:
user: ${DOCKER_USER:-1000}
image: impress:backend-development
@@ -131,7 +141,7 @@ services:
frontend-development:
user: "${DOCKER_USER:-1000}"
build:
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
@@ -161,13 +171,13 @@ services:
image: node:22
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"
y-provider-development:
user: ${DOCKER_USER:-1000}
build:
build:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider-development
@@ -209,7 +219,11 @@ services:
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
test:
[
"CMD-SHELL",
'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3',
]
start_period: 5s
interval: 1s
timeout: 2s
@@ -223,7 +237,7 @@ services:
KC_DB_PASSWORD: pass
KC_DB_USERNAME: impress
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
PROXY_ADDRESS_FORWARDING: "true"
ports:
- "8080:8080"
depends_on:

View File

@@ -17,9 +17,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
# Get resource from rustfs
proxy_pass http://rustfs:9000/impress-media-storage/;
proxy_set_header Host rustfs:9000;
add_header Content-Security-Policy "default-src 'none'" always;
}
@@ -30,7 +30,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";

View File

@@ -17,13 +17,15 @@ These are the environment variables you can set for the `impress-backend` contai
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| API_USERS_SEARCH_QUERY_MIN_LENGTH | Minimum characters to insert to search a user | 3 |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_S3_SIGNATURE_VERSION | S3 signature version (`s3v4` or `s3`) | s3v4 |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
@@ -118,6 +120,7 @@ These are the environment variables you can set for the `impress-backend` contai
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |

View File

@@ -67,6 +67,7 @@ backend:
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: docs-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"

View File

@@ -0,0 +1,30 @@
# User account reconciliation
It is possible to merge user accounts based on their email addresses.
Docs does not have an internal process to requests, but it allows the import of a CSV from an external form
(e.g. made with Grist) in the Django admin panel (in "Core" > "User reconciliation CSV imports" > "Add user reconciliation")
## CSV file format
The CSV must contain the following mandatory columns:
- `active_email`: the email of the user that will remain active after the process.
- `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts.
- `id`: a unique row id, so that entries already processed in a previous import are ignored.
The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.)
If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below)
Once the CSV file is processed, this will create entries in "Core" > "User reconciliations" and send verification emails to validate that the user making the request actually controls the email addresses (unless `active_email_checked` and `inactive_email_checked` were set to `1` in the CSV)
In "Core" > "User reconciliations", an admin can then select all rows they wish to process and check the action "Process selected user reconciliations". Only rows that have the status `ready` and for which both emails have been validated will be processed.
## Settings
If there is a problem with the reconciliation attempt (e.g., one of the addresses given by the user does not match an existing account), the email signaling the error can give back the link to the reconciliation form. This is configured through the following environment variable:
```env
USER_RECONCILIATION_FORM_URL=<url used in the email for reconciliation with errors to allow a new requests>
# e.g. "https://yourgristinstance.tld/xxxx/UserReconciliationForm"
```

View File

@@ -27,9 +27,9 @@ IMPRESS_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=impress
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=http://rustfs:9000
AWS_S3_ACCESS_KEY_ID=rustfsadmin
AWS_S3_SECRET_ACCESS_KEY=rustfsadmin
MEDIA_BASE_URL=http://localhost:8083
# OIDC
@@ -59,6 +59,9 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# User reconciliation
USER_RECONCILIATION_FORM_URL=http://localhost:3000
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com

View File

@@ -53,6 +53,9 @@ LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
# User reconciliation
#USER_RECONCILIATION_FORM_URL=https://${DOCS_HOST}
# AI
#AI_FEATURE_ENABLED=true # is false by default
#AI_BASE_URL=https://openaiendpoint.com

View File

@@ -1,12 +1,14 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from . import models
from core import models
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
@admin.register(models.User)
@@ -95,6 +97,44 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.UserReconciliationCsvImport)
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliationCsvImport model."""
list_display = ("id", "__str__", "created_at", "status")
def save_model(self, request, obj, form, change):
"""Override save_model to trigger the import task on creation."""
super().save_model(request, obj, form, change)
if not change:
user_reconciliation_csv_import_job.delay(obj.pk)
messages.success(request, _("Import job created and queued."))
return redirect("..")
@admin.action(description=_("Process selected user reconciliations"))
def process_reconciliation(_modeladmin, _request, queryset):
"""
Admin action to process selected user reconciliations.
The action will process only entries that are ready and have both emails checked.
"""
processable_entries = queryset.filter(
status="ready", active_email_checked=True, inactive_email_checked=True
)
for entry in processable_entries:
entry.process_reconciliation_request()
@admin.register(models.UserReconciliation)
class UserReconciliationAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliation model."""
list_display = ["id", "__str__", "created_at", "status"]
actions = [process_reconciliation]
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for document accesses."""

View File

@@ -2,6 +2,7 @@
import unicodedata
from django.conf import settings
from django.utils.translation import gettext_lazy as _
import django_filters
@@ -135,4 +136,6 @@ class UserSearchFilter(django_filters.FilterSet):
Custom filter for searching users.
"""
q = django_filters.CharFilter(min_length=5, max_length=254)
q = django_filters.CharFilter(
min_length=settings.API_USERS_SEARCH_QUERY_MIN_LENGTH, max_length=254
)

View File

@@ -37,9 +37,11 @@ from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from lasuite.tools.email import get_domain_from_email
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from core import authentication, choices, enums, models
from core.api.filters import remove_accents
@@ -61,7 +63,11 @@ from core.services.search_indexers import (
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants
from core.utils import (
extract_attachments,
filter_descendants,
users_sharing_documents_with,
)
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
@@ -220,18 +226,80 @@ class UserViewSet(
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes
# index, then only calculate precise similarity scores for sorting purposes.
#
# Additionally results are reordered to prefer users "closer" to the current
# user: users they recently shared documents with, then same email domain.
# To achieve that without complex SQL, we build a proximity score in Python
# and return the top N results.
# For security results, users that match neither of these proximity criteria
# are not returned at all, to prevent email enumeration.
current_user = self.request.user
shared_map = users_sharing_documents_with(current_user)
return (
user_email_domain = get_domain_from_email(current_user.email) or ""
candidates = list(
queryset.annotate(
sim_email=TrigramSimilarity("email", query),
sim_name=TrigramSimilarity("full_name", query),
)
.annotate(similarity=Greatest("sim_email", "sim_name"))
.filter(similarity__gt=0.2)
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
.order_by("-similarity")
)
# Keep only users that either share documents with the current user
# or have an email with the same domain as the current user.
filtered_candidates = []
for u in candidates:
candidate_domain = get_domain_from_email(u.email) or ""
if shared_map.get(u.id) or (
user_email_domain and candidate_domain == user_email_domain
):
filtered_candidates.append(u)
candidates = filtered_candidates
# Build ordering key for each candidate
def _sort_key(u):
# shared priority: most recent first
# Use shared_last_at timestamp numeric for secondary ordering when shared.
shared_last_at = shared_map.get(u.id)
if shared_last_at:
is_shared = 1
shared_score = int(shared_last_at.timestamp())
else:
is_shared = 0
shared_score = 0
# domain proximity
candidate_email_domain = get_domain_from_email(u.email) or ""
same_full_domain = (
1
if candidate_email_domain
and candidate_email_domain == user_email_domain
else 0
)
# similarity fallback
sim = getattr(u, "similarity", 0) or 0
return (
is_shared,
shared_score,
same_full_domain,
sim,
)
# Sort candidates by the key descending and return top N as a queryset-like
# list. Keep return type consistent with previous behavior (QuerySet slice
# was returned) by returning a list of model instances.
candidates.sort(key=_sort_key, reverse=True)
return candidates[: settings.API_USERS_LIST_LIMIT]
@drf.decorators.action(
detail=False,
methods=["get"],
@@ -249,6 +317,59 @@ class UserViewSet(
)
class ReconciliationConfirmView(APIView):
"""API endpoint to confirm user reconciliation emails.
GET /user-reconciliations/{user_type}/{confirmation_id}/
Marks `active_email_checked` or `inactive_email_checked` to True.
"""
permission_classes = [AllowAny]
def get(self, request, user_type, confirmation_id):
"""
Check the confirmation ID and mark the corresponding email as checked.
"""
try:
# validate UUID
uuid_obj = uuid.UUID(str(confirmation_id))
except ValueError:
return drf_response.Response(
{"detail": "Badly formatted confirmation id"},
status=status.HTTP_400_BAD_REQUEST,
)
if user_type not in ("active", "inactive"):
return drf_response.Response(
{"detail": "Invalid user_type"}, status=status.HTTP_400_BAD_REQUEST
)
lookup = (
{"active_email_confirmation_id": uuid_obj}
if user_type == "active"
else {"inactive_email_confirmation_id": uuid_obj}
)
try:
rec = models.UserReconciliation.objects.get(**lookup)
except models.UserReconciliation.DoesNotExist:
return drf_response.Response(
{"detail": "Reconciliation entry not found"},
status=status.HTTP_404_NOT_FOUND,
)
field_name = (
"active_email_checked"
if user_type == "active"
else "inactive_email_checked"
)
if not getattr(rec, field_name):
setattr(rec, field_name, True)
rec.save()
return drf_response.Response({"detail": "Confirmation received"})
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -2338,6 +2459,7 @@ class ConfigView(drf.views.APIView):
"""
array_settings = [
"AI_FEATURE_ENABLED",
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
"COLLABORATION_WS_URL",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
"CONVERSION_FILE_EXTENSIONS_ALLOWED",

View File

@@ -0,0 +1,178 @@
# Generated by Django 5.2.11 on 2026-02-10 15:47
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0028_remove_templateaccess_template_and_more"),
]
operations = [
migrations.CreateModel(
name="UserReconciliationCsvImport",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"file",
models.FileField(upload_to="imports/", verbose_name="CSV file"),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
],
options={
"verbose_name": "user reconciliation CSV import",
"verbose_name_plural": "user reconciliation CSV imports",
"db_table": "impress_user_reconciliation_csv_import",
},
),
migrations.CreateModel(
name="UserReconciliation",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"active_email",
models.EmailField(
max_length=254, verbose_name="Active email address"
),
),
(
"inactive_email",
models.EmailField(
max_length=254, verbose_name="Email address to deactivate"
),
),
("active_email_checked", models.BooleanField(default=False)),
("inactive_email_checked", models.BooleanField(default=False)),
(
"active_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"inactive_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"source_unique_id",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Unique ID in the source file",
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("ready", "Ready"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
(
"active_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="active_user",
to=settings.AUTH_USER_MODEL,
),
),
(
"inactive_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="inactive_user",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "user reconciliation",
"verbose_name_plural": "user reconciliations",
"db_table": "impress_user_reconciliation",
"ordering": ["-created_at"],
},
),
]

View File

@@ -15,7 +15,6 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -33,14 +32,14 @@ from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
from .choices import (
from core.choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
LinkRoleChoices,
RoleChoices,
get_equivalent_link_definition,
)
from .validators import sub_validator
from core.validators import sub_validator
logger = getLogger(__name__)
@@ -119,11 +118,11 @@ class UserManager(auth_models.UserManager):
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
return self.get(email__iexact=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
self.filter(email__iexact=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
@@ -251,11 +250,37 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
def send_email(self, subject, context=None, language=None):
"""Generate and send email to the user from a template."""
emails = [self.email]
context = context or {}
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"domain": domain,
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
with override(language):
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
@cached_property
def teams(self):
@@ -266,6 +291,417 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
return []
class UserReconciliation(BaseModel):
"""Model to run batch jobs to replace an active user by another one"""
active_email = models.EmailField(_("Active email address"))
inactive_email = models.EmailField(_("Email address to deactivate"))
active_email_checked = models.BooleanField(default=False)
inactive_email_checked = models.BooleanField(default=False)
active_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="active_user",
)
inactive_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="inactive_user",
)
active_email_confirmation_id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, null=True
)
inactive_email_confirmation_id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, null=True
)
source_unique_id = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name=_("Unique ID in the source file"),
)
status = models.CharField(
max_length=20,
choices=[
("pending", _("Pending")),
("ready", _("Ready")),
("done", _("Done")),
("error", _("Error")),
],
default="pending",
)
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation"
verbose_name = _("user reconciliation")
verbose_name_plural = _("user reconciliations")
ordering = ["-created_at"]
def __str__(self):
return f"Reconciliation from {self.inactive_email} to {self.active_email}"
def save(self, *args, **kwargs):
"""
For pending queries, identify the actual users and send validation emails
"""
if self.status == "pending":
self.active_user = User.objects.filter(email=self.active_email).first()
self.inactive_user = User.objects.filter(email=self.inactive_email).first()
if self.active_user and self.inactive_user:
if not self.active_email_checked:
self.send_reconciliation_confirm_email(
self.active_user, "active", self.active_email_confirmation_id
)
if not self.inactive_email_checked:
self.send_reconciliation_confirm_email(
self.inactive_user,
"inactive",
self.inactive_email_confirmation_id,
)
self.status = "ready"
else:
self.status = "error"
self.logs = "Error: Both active and inactive users need to exist."
super().save(*args, **kwargs)
@transaction.atomic
def process_reconciliation_request(self):
"""
Process the reconciliation request as a transaction.
- Transfer document accesses from inactive to active user, updating roles as needed.
- Transfer document favorites from inactive to active user.
- Transfer link traces from inactive to active user.
- Transfer comment-related content from inactive to active user
(threads, comments and reactions)
- Activate the active user and deactivate the inactive user.
- Update the reconciliation entry itself.
"""
# Prepare the data to perform the reconciliation on
updated_accesses, removed_accesses = (
self.prepare_documentaccess_reconciliation()
)
updated_linktraces, removed_linktraces = self.prepare_linktrace_reconciliation()
update_favorites, removed_favorites = (
self.prepare_document_favorite_reconciliation()
)
updated_threads = self.prepare_thread_reconciliation()
updated_comments = self.prepare_comment_reconciliation()
updated_reactions, removed_reactions = self.prepare_reaction_reconciliation()
self.active_user.is_active = True
self.inactive_user.is_active = False
# Actually perform the bulk operations
DocumentAccess.objects.bulk_update(updated_accesses, ["user", "role"])
if removed_accesses:
ids_to_delete = [entry.id for entry in removed_accesses]
DocumentAccess.objects.filter(id__in=ids_to_delete).delete()
DocumentFavorite.objects.bulk_update(update_favorites, ["user"])
if removed_favorites:
ids_to_delete = [entry.id for entry in removed_favorites]
DocumentFavorite.objects.filter(id__in=ids_to_delete).delete()
LinkTrace.objects.bulk_update(updated_linktraces, ["user"])
if removed_linktraces:
ids_to_delete = [entry.id for entry in removed_linktraces]
LinkTrace.objects.filter(id__in=ids_to_delete).delete()
Thread.objects.bulk_update(updated_threads, ["creator"])
Comment.objects.bulk_update(updated_comments, ["user"])
# pylint: disable=C0103
ReactionThroughModel = Reaction.users.through
reactions_to_create = []
for updated_reaction in updated_reactions:
reactions_to_create.append(
ReactionThroughModel(
user_id=self.active_user.pk, reaction_id=updated_reaction.pk
)
)
if reactions_to_create:
ReactionThroughModel.objects.bulk_create(reactions_to_create)
if removed_reactions:
ids_to_delete = [entry.id for entry in removed_reactions]
ReactionThroughModel.objects.filter(
reaction_id__in=ids_to_delete, user_id=self.inactive_user.pk
).delete()
User.objects.bulk_update([self.active_user, self.inactive_user], ["is_active"])
# Wrap up the reconciliation entry
self.logs += f"""Requested update for {len(updated_accesses)} DocumentAccess items
and deletion for {len(removed_accesses)} DocumentAccess items.\n"""
self.status = "done"
self.save()
self.send_reconciliation_done_email()
def prepare_documentaccess_reconciliation(self):
"""
Prepare the reconciliation by transferring document accesses from the inactive user
to the active user.
"""
updated_accesses = []
removed_accesses = []
inactive_accesses = DocumentAccess.objects.filter(user=self.inactive_user)
# Check documents where the active user already has access
inactive_accesses_documents = inactive_accesses.values_list(
"document", flat=True
)
existing_accesses = DocumentAccess.objects.filter(user=self.active_user).filter(
document__in=inactive_accesses_documents
)
existing_roles_per_doc = dict(existing_accesses.values_list("document", "role"))
for entry in inactive_accesses:
if entry.document_id in existing_roles_per_doc:
# Update role if needed
existing_role = existing_roles_per_doc[entry.document_id]
max_role = RoleChoices.max(entry.role, existing_role)
if existing_role != max_role:
existing_access = existing_accesses.get(document=entry.document)
existing_access.role = max_role
updated_accesses.append(existing_access)
removed_accesses.append(entry)
else:
entry.user = self.active_user
updated_accesses.append(entry)
return updated_accesses, removed_accesses
def prepare_document_favorite_reconciliation(self):
"""
Prepare the reconciliation by transferring document favorites from the inactive user
to the active user.
"""
updated_favorites = []
removed_favorites = []
existing_favorites = DocumentFavorite.objects.filter(user=self.active_user)
existing_favorite_doc_ids = set(
existing_favorites.values_list("document_id", flat=True)
)
inactive_favorites = DocumentFavorite.objects.filter(user=self.inactive_user)
for entry in inactive_favorites:
if entry.document_id in existing_favorite_doc_ids:
removed_favorites.append(entry)
else:
entry.user = self.active_user
updated_favorites.append(entry)
return updated_favorites, removed_favorites
def prepare_linktrace_reconciliation(self):
"""
Prepare the reconciliation by transferring link traces from the inactive user
to the active user.
"""
updated_linktraces = []
removed_linktraces = []
existing_linktraces = LinkTrace.objects.filter(user=self.active_user)
inactive_linktraces = LinkTrace.objects.filter(user=self.inactive_user)
for entry in inactive_linktraces:
if existing_linktraces.filter(document=entry.document).exists():
removed_linktraces.append(entry)
else:
entry.user = self.active_user
updated_linktraces.append(entry)
return updated_linktraces, removed_linktraces
def prepare_thread_reconciliation(self):
"""
Prepare the reconciliation by transferring threads from the inactive user
to the active user.
"""
updated_threads = []
inactive_threads = Thread.objects.filter(creator=self.inactive_user)
for entry in inactive_threads:
entry.creator = self.active_user
updated_threads.append(entry)
return updated_threads
def prepare_comment_reconciliation(self):
"""
Prepare the reconciliation by transferring comments from the inactive user
to the active user.
"""
updated_comments = []
inactive_comments = Comment.objects.filter(user=self.inactive_user)
for entry in inactive_comments:
entry.user = self.active_user
updated_comments.append(entry)
return updated_comments
def prepare_reaction_reconciliation(self):
"""
Prepare the reconciliation by creating missing reactions for the active user
(ie, the ones that exist for the inactive user but not the active user)
and then deleting all reactions of the inactive user.
"""
inactive_reactions = Reaction.objects.filter(users=self.inactive_user)
updated_reactions = inactive_reactions.exclude(users=self.active_user)
return updated_reactions, inactive_reactions
def send_reconciliation_confirm_email(
self, user, user_type, confirmation_id, language=None
):
"""Method allowing to send confirmation email for reconciliation requests."""
language = language or get_language()
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
message = _(
"""You have requested a reconciliation of your user accounts on Docs.
To confirm that you are the one who initiated the request
and that this email belongs to you:"""
)
with override(language):
subject = _("Confirm by clicking the link to start the reconciliation")
context = {
"title": subject,
"message": message,
"link": f"{domain}/user-reconciliations/{user_type}/{confirmation_id}/",
"link_label": str(_("Click here")),
"button_label": str(_("Confirm")),
}
user.send_email(subject, context, language)
def send_reconciliation_done_email(self, language=None):
"""Method allowing to send done email for reconciliation requests."""
language = language or get_language()
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
message = _(
"""Your reconciliation request has been processed.
New documents are likely associated with your account:"""
)
with override(language):
subject = _("Your accounts have been merged")
context = {
"title": subject,
"message": message,
"link": f"{domain}/",
"link_label": str(_("Click here to see")),
"button_label": str(_("See my documents")),
}
self.active_user.send_email(subject, context, language)
class UserReconciliationCsvImport(BaseModel):
"""Model to import reconciliations requests from an external source
(eg, )"""
file = models.FileField(upload_to="imports/", verbose_name=_("CSV file"))
status = models.CharField(
max_length=20,
choices=[
("pending", _("Pending")),
("running", _("Running")),
("done", _("Done")),
("error", _("Error")),
],
default="pending",
)
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation_csv_import"
verbose_name = _("user reconciliation CSV import")
verbose_name_plural = _("user reconciliation CSV imports")
def __str__(self):
return f"User reconciliation CSV import {self.id}"
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email to the user from a template."""
context = context or {}
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"domain": domain,
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
with override(language):
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
def send_reconciliation_error_email(
self, recipient_email, other_email, language=None
):
"""Method allowing to send email for reconciliation requests with errors."""
language = language or get_language()
emails = [recipient_email]
message = _(
"""Your request for reconciliation was unsuccessful.
Reconciliation failed for the following email addresses:
{recipient_email}, {other_email}.
Please check for typos.
You can submit another request with the valid email addresses."""
).format(recipient_email=recipient_email, other_email=other_email)
with override(language):
subject = _("Reconciliation of your Docs accounts not completed")
context = {
"title": subject,
"message": message,
"link": settings.USER_RECONCILIATION_FORM_URL,
"link_label": str(_("Click here")),
"button_label": str(_("Make a new request")),
}
self.send_email(subject, emails, context, language)
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
@@ -1469,7 +1905,7 @@ class Invitation(BaseModel):
# Check if an identity already exists for the provided email
if (
User.objects.filter(email=self.email).exists()
User.objects.filter(email__iexact=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValidationError(

View File

@@ -4,12 +4,14 @@ Declare and configure the signals for the impress core application
from functools import partial
from django.core.cache import cache
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from . import models
from .tasks.search import trigger_batch_document_indexer
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
@receiver(signals.post_save, sender=models.Document)
@@ -26,8 +28,24 @@ def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-ar
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Clear cache for the affected user.
"""
if not created:
transaction.on_commit(
partial(trigger_batch_document_indexer, instance.document)
)
# Invalidate cache for the user
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)
@receiver(signals.post_delete, sender=models.DocumentAccess)
def document_access_post_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Clear cache for the affected user when document access is deleted.
"""
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)

View File

@@ -0,0 +1,135 @@
"""Processing tasks for user reconciliation CSV imports."""
import csv
import traceback
import uuid
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import IntegrityError
from botocore.exceptions import ClientError
from core.models import UserReconciliation, UserReconciliationCsvImport
from impress.celery_app import app
def _process_row(row, job, counters):
"""Process a single row from the CSV file."""
source_unique_id = row["id"].strip()
# Skip entries if they already exist with this source_unique_id
if UserReconciliation.objects.filter(source_unique_id=source_unique_id).exists():
counters["already_processed_source_ids"] += 1
return counters
active_email_checked = row.get("active_email_checked", "0") == "1"
inactive_email_checked = row.get("inactive_email_checked", "0") == "1"
active_email = row["active_email"]
inactive_emails = row["inactive_email"].split("|")
try:
validate_email(active_email)
except ValidationError:
job.send_reconciliation_error_email(
recipient_email=inactive_emails[0], other_email=active_email
)
job.logs += f"Invalid active email address on row {source_unique_id}."
counters["rows_with_errors"] += 1
return counters
for inactive_email in inactive_emails:
try:
validate_email(inactive_email)
except (ValidationError, ValueError):
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += f"Invalid inactive email address on row {source_unique_id}.\n"
counters["rows_with_errors"] += 1
continue
if inactive_email == active_email:
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += (
f"Error on row {source_unique_id}: "
f"{active_email} set as both active and inactive email.\n"
)
counters["rows_with_errors"] += 1
continue
_rec_entry = UserReconciliation.objects.create(
active_email=active_email,
inactive_email=inactive_email,
active_email_checked=active_email_checked,
inactive_email_checked=inactive_email_checked,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
source_unique_id=source_unique_id,
status="pending",
)
counters["rec_entries_created"] += 1
return counters
@app.task
def user_reconciliation_csv_import_job(job_id):
"""Process a UserReconciliationCsvImport job.
Creates UserReconciliation entries from the CSV file.
Does some sanity checks on the data:
- active_email and inactive_email must be valid email addresses
- active_email and inactive_email cannot be the same
Rows with errors are logged in the job logs and skipped, but do not cause
the entire job to fail or prevent the next rows from being processed.
"""
# Imports the CSV file, breaks it into UserReconciliation items
job = UserReconciliationCsvImport.objects.get(id=job_id)
job.status = "running"
job.save()
counters = {
"rec_entries_created": 0,
"rows_with_errors": 0,
"already_processed_source_ids": 0,
}
try:
with job.file.open(mode="r") as f:
reader = csv.DictReader(f)
if not {"active_email", "inactive_email", "id"}.issubset(reader.fieldnames):
raise KeyError(
"CSV is missing mandatory columns: active_email, inactive_email, id"
)
for row in reader:
counters = _process_row(row, job, counters)
job.status = "done"
job.logs += (
f"Import completed successfully. {reader.line_num} rows processed."
f" {counters['rec_entries_created']} reconciliation entries created."
f" {counters['already_processed_source_ids']} rows were already processed."
f" {counters['rows_with_errors']} rows had errors."
)
except (
csv.Error,
KeyError,
ValidationError,
ValueError,
IntegrityError,
OSError,
ClientError,
) as e:
# Catch expected I/O/CSV/model errors and record traceback in logs for debugging
job.status = "error"
job.logs += f"{e!s}\n{traceback.format_exc()}"
finally:
job.save()

View File

@@ -68,6 +68,30 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_existing_user_via_email_case_insensitive(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email with different case,
the user should be returned (case-insensitive email matching).
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
@@ -157,6 +181,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate_case_insensitive(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should detect duplicate emails even with different case.
"""
klass = OIDCAuthenticationBackend()
_db_user = UserFactory(email="john.doe@example.com")
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):

View File

@@ -0,0 +1,6 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com","user.test41@example.com",0,0,pending,1
"user.test42@example.com","user.test43@example.com",0,1,pending,2
"user.test44@example.com","user.test45@example.com",1,0,pending,3
"user.test46@example.com","user.test47@example.com",1,1,pending,4
"user.test48@example.com","user.test49@example.com",1,1,pending,5
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com user.test41@example.com 0 0 pending 1
3 user.test42@example.com user.test43@example.com 0 1 pending 2
4 user.test44@example.com user.test45@example.com 1 0 pending 3
5 user.test46@example.com user.test47@example.com 1 1 pending 4
6 user.test48@example.com user.test49@example.com 1 1 pending 5

View File

@@ -0,0 +1,2 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com",,0,0,pending,40
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com 0 0 pending 40

View File

@@ -0,0 +1,5 @@
merge_accept,active_email,inactive_email,status,id
true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending,10
true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending,11
true,user.test20@example.com,user.test21@example.com,pending,12
true,user.test22@example.com,user.test23@example.com,pending,13
1 merge_accept active_email inactive_email status id
2 true user.test10@example.com user.test11@example.com|user.test12@example.com pending 10
3 true user.test30@example.com user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com pending 11
4 true user.test20@example.com user.test21@example.com pending 12
5 true user.test22@example.com user.test23@example.com pending 13

View File

@@ -0,0 +1,2 @@
merge_accept,active_email,inactive_email,status,id
true,user.test20@example.com,user.test20@example.com,pending,20
1 merge_accept active_email inactive_email status id
2 true user.test20@example.com user.test20@example.com pending 20

View File

@@ -0,0 +1,6 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status
"user.test40@example.com","user.test41@example.com",0,0,pending
"user.test42@example.com","user.test43@example.com",0,1,pending
"user.test44@example.com","user.test45@example.com",1,0,pending
"user.test46@example.com","user.test47@example.com",1,1,pending
"user.test48@example.com","user.test49@example.com",1,1,pending
1 active_email inactive_email active_email_checked inactive_email_checked status
2 user.test40@example.com user.test41@example.com 0 0 pending
3 user.test42@example.com user.test43@example.com 0 1 pending
4 user.test44@example.com user.test45@example.com 1 0 pending
5 user.test46@example.com user.test47@example.com 1 1 pending
6 user.test48@example.com user.test49@example.com 1 1 pending

View File

@@ -596,6 +596,38 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
}
def test_api_item_invitations_create_cannot_invite_existing_users_case_insensitive():
"""
It should not be possible to invite already existing users, even with different email case.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an existing identity with different case
invitation_values = {
"email": existing_user.email.upper(),
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
client.force_login(user)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 400
assert response.json() == {
"email": ["This email is already associated to a registered user."]
}
def test_api_document_invitations_create_lower_email():
"""
No matter the case, the email should be converted to lowercase.

View File

@@ -20,6 +20,7 @@ pytestmark = pytest.mark.django_db
@override_settings(
AI_FEATURE_ENABLED=False,
API_USERS_SEARCH_QUERY_MIN_LENGTH=6,
COLLABORATION_WS_URL="http://testcollab/",
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
CRISP_WEBSITE_ID="123",
@@ -44,6 +45,7 @@ def test_api_config(is_authenticated):
assert response.status_code == HTTP_200_OK
assert response.json() == {
"AI_FEATURE_ENABLED": False,
"API_USERS_SEARCH_QUERY_MIN_LENGTH": 6,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],

View File

@@ -0,0 +1,85 @@
"""
Unit tests for the ReconciliationConfirmView API view.
"""
import uuid
from django.conf import settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_reconciliation_confirm_view_sets_active_checked():
"""GETting the active confirmation endpoint should set active_email_checked."""
user = factories.UserFactory(email="user.confirm1@example.com")
other = factories.UserFactory(email="user.confirm2@example.com")
rec = models.UserReconciliation.objects.create(
active_email=user.email,
inactive_email=other.email,
active_user=user,
inactive_user=other,
active_email_checked=False,
inactive_email_checked=False,
status="ready",
)
client = APIClient()
conf_id = rec.active_email_confirmation_id
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{conf_id}/"
resp = client.get(url)
assert resp.status_code == 200
assert resp.json() == {"detail": "Confirmation received"}
rec.refresh_from_db()
assert rec.active_email_checked is True
def test_reconciliation_confirm_view_sets_inactive_checked():
"""GETting the inactive confirmation endpoint should set inactive_email_checked."""
user = factories.UserFactory(email="user.confirm3@example.com")
other = factories.UserFactory(email="user.confirm4@example.com")
rec = models.UserReconciliation.objects.create(
active_email=user.email,
inactive_email=other.email,
active_user=user,
inactive_user=other,
active_email_checked=False,
inactive_email_checked=False,
status="ready",
)
client = APIClient()
conf_id = rec.inactive_email_confirmation_id
url = f"/api/{settings.API_VERSION}/user-reconciliations/inactive/{conf_id}/"
resp = client.get(url)
assert resp.status_code == 200
assert resp.json() == {"detail": "Confirmation received"}
rec.refresh_from_db()
assert rec.inactive_email_checked is True
def test_reconciliation_confirm_view_invalid_user_type_returns_400():
"""GETting with an invalid user_type should return 400."""
client = APIClient()
# Use a valid uuid format but invalid user_type
url = f"/api/{settings.API_VERSION}/user-reconciliations/other/{uuid.uuid4()}/"
resp = client.get(url)
assert resp.status_code == 400
assert resp.json() == {"detail": "Invalid user_type"}
def test_reconciliation_confirm_view_not_found_returns_404():
"""GETting with a non-existing confirmation_id should return 404."""
client = APIClient()
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{uuid.uuid4()}/"
resp = client.get(url)
assert resp.status_code == 404
assert resp.json() == {"detail": "Reconciliation entry not found"}

View File

@@ -2,6 +2,8 @@
Test users API endpoints in the impress core app.
"""
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -121,12 +123,12 @@ def test_api_users_list_query_full_name():
Authenticated users should be able to list users and filter by full name.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
dave = factories.UserFactory(email="contact@example.com", full_name="David Bowman")
response = client.get(
"/api/v1.0/users/?q=David",
@@ -166,13 +168,13 @@ def test_api_users_list_query_accented_full_name():
Authenticated users should be able to list users and filter by full name with accents.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
fred = factories.UserFactory(
email="contact@work.com", full_name="Frédérique Lefèvre"
email="contact@example.com", full_name="Frédérique Lefèvre"
)
response = client.get("/api/v1.0/users/?q=Frédérique")
@@ -201,12 +203,82 @@ def test_api_users_list_query_accented_full_name():
assert users == []
def test_api_users_list_sorted_by_closest_match():
"""
Authenticated users should be able to list users and the results should be
sorted by closest match to the query.
Sorting criteria are :
- Shared documents with the user (most recent first)
- Same full email domain (example.gouv.fr)
Addresses that match neither criteria should be excluded from the results.
Case in point: the logged-in user has recently shared documents
with pierre.dupont@beta.gouv.fr and less recently with pierre.durand@impots.gouv.fr.
Other users named Pierre also exist:
- pierre.thomas@example.com
- pierre.petit@anct.gouv.fr
- pierre.robert@culture.gouv.fr
The search results should be ordered as follows:
# Shared with first
- pierre.dupond@beta.gouv.fr # Most recent first
- pierre.durand@impots.gouv.fr
# Same full domain second
- pierre.petit@anct.gouv.fr
"""
user = factories.UserFactory(
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
)
client = APIClient()
client.force_login(user)
pierre_1 = factories.UserFactory(email="pierre.dupont@beta.gouv.fr")
pierre_2 = factories.UserFactory(email="pierre.durand@impots.gouv.fr")
_pierre_3 = factories.UserFactory(email="pierre.thomas@example.com")
pierre_4 = factories.UserFactory(email="pierre.petit@anct.gouv.fr")
_pierre_5 = factories.UserFactory(email="pierre.robert@culture.gouv.fr")
document_1 = factories.DocumentFactory(creator=user)
document_2 = factories.DocumentFactory(creator=user)
factories.UserDocumentAccessFactory(user=user, document=document_1)
factories.UserDocumentAccessFactory(user=user, document=document_2)
now = timezone.now()
last_week = now - timezone.timedelta(days=7)
last_month = now - timezone.timedelta(days=30)
# The factory cannot set the created_at directly, so we force it after creation
p1_d1 = factories.UserDocumentAccessFactory(user=pierre_1, document=document_1)
p1_d1.created_at = last_week
p1_d1.save()
p2_d2 = factories.UserDocumentAccessFactory(user=pierre_2, document=document_2)
p2_d2.created_at = last_month
p2_d2.save()
response = client.get("/api/v1.0/users/?q=Pierre")
assert response.status_code == 200
user_ids = [user["email"] for user in response.json()]
assert user_ids == [
str(pierre_1.email),
str(pierre_2.email),
str(pierre_4.email),
]
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
should be limited to 10.
should be limited to API_USERS_LIST_LIMIT (by default 5).
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
@@ -309,28 +381,16 @@ def test_api_users_list_query_email_exclude_doc_user():
def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
If API_USERS_SEARCH_QUERY_MIN_LENGTH is not set, the default minimum length should be 3.
"""
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
factories.UserFactory(email="john.doe@example.com", full_name="John Doe")
factories.UserFactory(email="john.lennon@example.com", full_name="John Lennon")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 2)."]
}
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 4)."]
}
response = client.get("/api/v1.0/users/?q=john.")
response = client.get("/api/v1.0/users/?q=joh")
assert response.status_code == 200
assert len(response.json()) == 2
@@ -356,7 +416,7 @@ def test_api_users_list_query_long_queries():
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)

View File

@@ -0,0 +1,669 @@
"""
Unit tests for the UserReconciliationCsvImport model
"""
import uuid
from pathlib import Path
from django.core import mail
from django.core.files.base import ContentFile
import pytest
from core import factories, models
from core.admin import process_reconciliation
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
pytestmark = pytest.mark.django_db
@pytest.fixture(name="import_example_csv_basic")
def fixture_import_example_csv_basic():
"""
Import an example CSV file for user reconciliation
and return the created import object.
"""
# Create users referenced in the CSV
for i in range(40, 50):
factories.UserFactory(email=f"user.test{i}@example.com")
example_csv_path = Path(__file__).parent / "data/example_reconciliation_basic.csv"
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_basic.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
return csv_import
@pytest.fixture(name="import_example_csv_grist_form")
def fixture_import_example_csv_grist_form():
"""
Import an example CSV file for user reconciliation
and return the created import object.
"""
# Create users referenced in the CSV
for i in range(10, 40):
factories.UserFactory(email=f"user.test{i}@example.com")
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_grist_form.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_grist_form.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
return csv_import
def test_user_reconciliation_csv_import_entry_is_created(import_example_csv_basic):
"""Test that a UserReconciliationCsvImport entry is created correctly."""
assert import_example_csv_basic.status == "pending"
assert import_example_csv_basic.file.name.endswith(
"example_reconciliation_basic.csv"
)
def test_user_reconciliation_csv_import_entry_is_created_grist_form(
import_example_csv_grist_form,
):
"""Test that a UserReconciliationCsvImport entry is created correctly."""
assert import_example_csv_grist_form.status == "pending"
assert import_example_csv_grist_form.file.name.endswith(
"example_reconciliation_grist_form.csv"
)
def test_incorrect_csv_format_handling():
"""Test that an incorrectly formatted CSV file is handled gracefully."""
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_missing_column.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(
f.read(), name="example_reconciliation_missing_column.csv"
)
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert (
"CSV is missing mandatory columns: active_email, inactive_email, id"
in csv_import.logs
)
assert csv_import.status == "error"
def test_incorrect_email_format_handling():
"""Test that an incorrectly formatted CSV file is handled gracefully."""
example_csv_path = Path(__file__).parent / "data/example_reconciliation_error.csv"
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_error.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert "Invalid inactive email address on row 40" in csv_import.logs
assert csv_import.status == "done"
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["user.test40@example.com"]
email_content = " ".join(email.body.split())
assert "Reconciliation of your Docs accounts not completed" in email_content
def test_incorrect_csv_data_handling_grist_form():
"""Test that a CSV file with incorrect data is handled gracefully."""
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_grist_form_error.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(
f.read(), name="example_reconciliation_grist_form_error.csv"
)
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert (
"user.test20@example.com set as both active and inactive email"
in csv_import.logs
)
assert csv_import.status == "done"
def test_job_creates_reconciliation_entries(import_example_csv_basic):
"""Test that the CSV import job creates UserReconciliation entries."""
assert import_example_csv_basic.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_basic.id)
# Verify the job status changed
import_example_csv_basic.refresh_from_db()
assert import_example_csv_basic.status == "done"
assert "Import completed successfully." in import_example_csv_basic.logs
assert "6 rows processed." in import_example_csv_basic.logs
assert "5 reconciliation entries created." in import_example_csv_basic.logs
# Verify reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 5
def test_job_does_not_create_duplicated_reconciliation_entries(
import_example_csv_basic,
):
"""Test that the CSV import job doesn't create UserReconciliation entries
for source unique IDs that have already been processed."""
_already_created_entry = models.UserReconciliation.objects.create(
active_email="user.test40@example.com",
inactive_email="user.test41@example.com",
active_email_checked=0,
inactive_email_checked=0,
status="pending",
source_unique_id=1,
)
assert import_example_csv_basic.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_basic.id)
# Verify the job status changed
import_example_csv_basic.refresh_from_db()
assert import_example_csv_basic.status == "done"
assert "Import completed successfully." in import_example_csv_basic.logs
assert "6 rows processed." in import_example_csv_basic.logs
assert "4 reconciliation entries created." in import_example_csv_basic.logs
assert "1 rows were already processed." in import_example_csv_basic.logs
# Verify the correct number of reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 5
def test_job_creates_reconciliation_entries_grist_form(import_example_csv_grist_form):
"""Test that the CSV import job creates UserReconciliation entries."""
assert import_example_csv_grist_form.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_grist_form.id)
# Verify the job status changed
import_example_csv_grist_form.refresh_from_db()
assert "Import completed successfully" in import_example_csv_grist_form.logs
assert import_example_csv_grist_form.status == "done"
# Verify reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 9
def test_csv_import_reconciliation_data_is_correct(import_example_csv_basic):
"""Test that the data in created UserReconciliation entries matches the CSV."""
user_reconciliation_csv_import_job(import_example_csv_basic.id)
reconciliations = models.UserReconciliation.objects.order_by("created_at")
first_entry = reconciliations.first()
assert first_entry.active_email == "user.test40@example.com"
assert first_entry.inactive_email == "user.test41@example.com"
assert first_entry.active_email_checked is False
assert first_entry.inactive_email_checked is False
for rec in reconciliations:
assert rec.status == "ready"
@pytest.fixture(name="user_reconciliation_users_and_docs")
def fixture_user_reconciliation_users_and_docs():
"""Fixture to create two users with overlapping document accesses
for reconciliation tests."""
user_1 = factories.UserFactory(email="user.test1@example.com")
user_2 = factories.UserFactory(email="user.test2@example.com")
# Create 10 distinct document accesses for each user
userdocs_u1 = [
factories.UserDocumentAccessFactory(user=user_1, role="editor")
for _ in range(10)
]
userdocs_u2 = [
factories.UserDocumentAccessFactory(user=user_2, role="editor")
for _ in range(10)
]
# Make the first 3 documents of each list shared with the other user
# with a lower role
for ud in userdocs_u1[0:3]:
factories.UserDocumentAccessFactory(
user=user_2, document=ud.document, role="reader"
)
for ud in userdocs_u2[0:3]:
factories.UserDocumentAccessFactory(
user=user_1, document=ud.document, role="reader"
)
# Make the next 3 documents of each list shared with the other user
# with a higher role
for ud in userdocs_u1[3:6]:
factories.UserDocumentAccessFactory(
user=user_2, document=ud.document, role="owner"
)
for ud in userdocs_u2[3:6]:
factories.UserDocumentAccessFactory(
user=user_1, document=ud.document, role="owner"
)
return (user_1, user_2, userdocs_u1, userdocs_u2)
def test_user_reconciliation_is_created(user_reconciliation_users_and_docs):
"""Test that a UserReconciliation entry can be created and saved."""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=False,
inactive_email_checked=True,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
status="pending",
)
rec.save()
assert rec.status == "ready"
def test_user_reconciliation_verification_emails_are_sent(
user_reconciliation_users_and_docs,
):
"""Test that both UserReconciliation verification emails are sent."""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=False,
inactive_email_checked=False,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
status="pending",
)
rec.save()
# pylint: disable-next=no-member
assert len(mail.outbox) == 2
# pylint: disable-next=no-member
email_1 = mail.outbox[0]
assert email_1.to == [user_1.email]
email_1_content = " ".join(email_1.body.split())
assert (
"You have requested a reconciliation of your user accounts on Docs."
in email_1_content
)
active_email_confirmation_id = rec.active_email_confirmation_id
inactive_email_confirmation_id = rec.inactive_email_confirmation_id
assert (
f"user-reconciliations/active/{active_email_confirmation_id}/"
in email_1_content
)
# pylint: disable-next=no-member
email_2 = mail.outbox[1]
assert email_2.to == [user_2.email]
email_2_content = " ".join(email_2.body.split())
assert (
"You have requested a reconciliation of your user accounts on Docs."
in email_2_content
)
assert (
f"user-reconciliations/inactive/{inactive_email_confirmation_id}/"
in email_2_content
)
def test_user_reconciliation_only_starts_if_checks_are_made(
user_reconciliation_users_and_docs,
):
"""Test that the admin action does not process entries
unless both email checks are confirmed.
"""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
# Create a reconciliation entry where only one email has been checked
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=True,
inactive_email_checked=False,
status="pending",
)
rec.save()
# Capture counts before running admin action
accesses_before_active = models.DocumentAccess.objects.filter(user=user_1).count()
accesses_before_inactive = models.DocumentAccess.objects.filter(user=user_2).count()
users_active_before = (user_1.is_active, user_2.is_active)
# Call the admin action with the queryset containing our single rec
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
# Reload from DB and assert nothing was processed (checks prevent processing)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
assert rec.status == "ready"
assert (
models.DocumentAccess.objects.filter(user=user_1).count()
== accesses_before_active
)
assert (
models.DocumentAccess.objects.filter(user=user_2).count()
== accesses_before_inactive
)
assert (user_1.is_active, user_2.is_active) == users_active_before
def test_process_reconciliation_updates_accesses(
user_reconciliation_users_and_docs,
):
"""Test that accesses are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
u2doc1 = userdocs_u2[1].document
u2doc5 = userdocs_u2[5].document
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# After processing, inactive user should have no accesses
# and active user should have one access per union document
# with the highest role
assert rec.status == "done"
assert "Requested update for 10 DocumentAccess items" in rec.logs
assert "and deletion for 12 DocumentAccess items" in rec.logs
assert models.DocumentAccess.objects.filter(user=user_2).count() == 0
assert models.DocumentAccess.objects.filter(user=user_1).count() == 20
assert u1_2.role == "editor"
assert u1_5.role == "owner"
assert (
models.DocumentAccess.objects.filter(user=user_1, document=u2doc1).first().role
== "editor"
)
assert (
models.DocumentAccess.objects.filter(user=user_1, document=u2doc5).first().role
== "owner"
)
assert user_1.is_active is True
assert user_2.is_active is False
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [user_1.email]
email_content = " ".join(email.body.split())
assert "Your accounts have been merged" in email_content
def test_process_reconciliation_updates_linktraces(
user_reconciliation_users_and_docs,
):
"""Test that linktraces are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
doc_both = u1_2.document
models.LinkTrace.objects.create(document=doc_both, user=user_1)
models.LinkTrace.objects.create(document=doc_both, user=user_2)
doc_inactive_only = userdocs_u2[4].document
models.LinkTrace.objects.create(
document=doc_inactive_only, user=user_2, is_masked=True
)
doc_active_only = userdocs_u1[4].document
models.LinkTrace.objects.create(document=doc_active_only, user=user_1)
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# Inactive user should have no linktraces
assert models.LinkTrace.objects.filter(user=user_2).count() == 0
# doc_both should have a single LinkTrace owned by the active user
assert (
models.LinkTrace.objects.filter(user=user_1, document=doc_both).exists() is True
)
assert models.LinkTrace.objects.filter(user=user_1, document=doc_both).count() == 1
assert (
models.LinkTrace.objects.filter(user=user_2, document=doc_both).exists()
is False
)
# doc_inactive_only should now be linked to active user and preserve is_masked
lt = models.LinkTrace.objects.filter(
user=user_1, document=doc_inactive_only
).first()
assert lt is not None
assert lt.is_masked is True
# doc_active_only should still belong to active user
assert models.LinkTrace.objects.filter(
user=user_1, document=doc_active_only
).exists()
def test_process_reconciliation_updates_threads_comments_reactions(
user_reconciliation_users_and_docs,
):
"""Test that threads, comments and reactions are transferred/deduplicated
on reconciliation."""
user_1, user_2, _userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
# Use a document from the inactive user's set
document = userdocs_u2[0].document
# Thread and comment created by inactive user -> should be moved to active
thread = factories.ThreadFactory(document=document, creator=user_2)
comment = factories.CommentFactory(thread=thread, user=user_2)
# Reaction where only inactive user reacted -> should be moved to active user
reaction_inactive_only = factories.ReactionFactory(comment=comment, users=[user_2])
# Reaction where both users reacted -> inactive user's participation should be removed
thread2 = factories.ThreadFactory(document=document, creator=user_1)
comment2 = factories.CommentFactory(thread=thread2, user=user_1)
reaction_both = factories.ReactionFactory(comment=comment2, users=[user_1, user_2])
# Reaction where only active user reacted -> unchanged
thread3 = factories.ThreadFactory(document=document, creator=user_1)
comment3 = factories.CommentFactory(thread=thread3, user=user_1)
reaction_active_only = factories.ReactionFactory(comment=comment3, users=[user_1])
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
# Refresh objects
thread.refresh_from_db()
comment.refresh_from_db()
reaction_inactive_only.refresh_from_db()
reaction_both.refresh_from_db()
reaction_active_only.refresh_from_db()
# Thread and comment creator should now be the active user
assert thread.creator == user_1
assert comment.user == user_1
# reaction_inactive_only: inactive user's participation should be removed and
# active user's participation added
reaction_inactive_only.refresh_from_db()
assert not reaction_inactive_only.users.filter(pk=user_2.pk).exists()
assert reaction_inactive_only.users.filter(pk=user_1.pk).exists()
# reaction_both: should end up with only active user's participation
assert reaction_both.users.filter(pk=user_2.pk).exists() is False
assert reaction_both.users.filter(pk=user_1.pk).exists() is True
# reaction_active_only should still have active user's participation
assert reaction_active_only.users.filter(pk=user_1.pk).exists()
def test_process_reconciliation_updates_favorites(
user_reconciliation_users_and_docs,
):
"""Test that favorites are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
doc_both = u1_2.document
models.DocumentFavorite.objects.create(document=doc_both, user=user_1)
models.DocumentFavorite.objects.create(document=doc_both, user=user_2)
doc_inactive_only = userdocs_u2[4].document
models.DocumentFavorite.objects.create(document=doc_inactive_only, user=user_2)
doc_active_only = userdocs_u1[4].document
models.DocumentFavorite.objects.create(document=doc_active_only, user=user_1)
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# Inactive user should have no document favorites
assert models.DocumentFavorite.objects.filter(user=user_2).count() == 0
# doc_both should have a single DocumentFavorite owned by the active user
assert (
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).exists()
is True
)
assert (
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).count()
== 1
)
assert (
models.DocumentFavorite.objects.filter(user=user_2, document=doc_both).exists()
is False
)
# doc_inactive_only should now be linked to active user
assert (
models.DocumentFavorite.objects.filter(
user=user_2, document=doc_inactive_only
).count()
== 0
)
assert models.DocumentFavorite.objects.filter(
user=user_1, document=doc_inactive_only
).exists()
# doc_active_only should still belong to active user
assert models.DocumentFavorite.objects.filter(
user=user_1, document=doc_active_only
).exists()

View File

@@ -2,8 +2,6 @@
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError
import pytest
@@ -26,26 +24,6 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
def test_models_users_send_mail_main_missing():
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
@pytest.mark.parametrize(
"sub,is_valid",
[

View File

@@ -3,9 +3,14 @@
import base64
import uuid
import pycrdt
from django.core.cache import cache
from core import utils
import pycrdt
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
# This base64 string is an example of what is saved in the database.
# This base64 is generated from the blocknote editor, it contains
@@ -100,3 +105,103 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}
def test_utils_users_sharing_documents_with_cache_miss():
"""Test cache miss: should query database and cache result."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
user3 = factories.UserFactory()
doc1 = factories.DocumentFactory()
doc2 = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
factories.UserDocumentAccessFactory(user=user3, document=doc2)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
assert user2.id in result
cached_data = cache.get(cache_key)
assert cached_data == result
def test_utils_users_sharing_documents_with_cache_hit():
"""Test cache hit: should return cached data without querying database."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
test_cached_data = {user2.id: "2025-02-10"}
cache.set(cache_key, test_cached_data, 86400)
result = utils.users_sharing_documents_with(user1)
assert result == test_cached_data
def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
"""Test that cache is invalidated when a DocumentAccess is created."""
# Create test data
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
# Pre-populate cache
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {}, 86400)
# Verify cache exists
assert cache.get(cache_key) is not None
# Create new DocumentAccess
factories.UserDocumentAccessFactory(user=user2, document=doc1)
# Cache should still exist (only created for user2 who was added)
# But if we create access for user1 being shared with, cache should be cleared
cache.set(cache_key, {"test": "data"}, 86400)
factories.UserDocumentAccessFactory(user=user1, document=doc1)
# Cache for user1 should be invalidated (cleared)
assert cache.get(cache_key) is None
def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
"""Test that cache is invalidated when a DocumentAccess is deleted."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {user2.id: "2025-02-10"}, 86400)
assert cache.get(cache_key) is not None
doc_access.delete()
assert cache.get(cache_key) is None
def test_utils_users_sharing_documents_with_empty_result():
"""Test when user is not sharing any documents."""
user1 = factories.UserFactory()
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
assert result == {}
cached_data = cache.get(cache_key)
assert cached_data == {}

View File

@@ -0,0 +1,62 @@
"""Tests for utils.users_sharing_documents_with function."""
from django.utils import timezone
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
def test_utils_users_sharing_documents_with():
"""Test users_sharing_documents_with function."""
user = factories.UserFactory(
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
)
pierre_1 = factories.UserFactory(
email="pierre.dupont@beta.gouv.fr", full_name="Pierre Dupont"
)
pierre_2 = factories.UserFactory(
email="pierre.durand@impots.gouv.fr", full_name="Pierre Durand"
)
now = timezone.now()
yesterday = now - timezone.timedelta(days=1)
last_week = now - timezone.timedelta(days=7)
last_month = now - timezone.timedelta(days=30)
document_1 = factories.DocumentFactory(creator=user)
document_2 = factories.DocumentFactory(creator=user)
document_3 = factories.DocumentFactory(creator=user)
factories.UserDocumentAccessFactory(user=user, document=document_1)
factories.UserDocumentAccessFactory(user=user, document=document_2)
factories.UserDocumentAccessFactory(user=user, document=document_3)
# The factory cannot set the created_at directly, so we force it after creation
doc_1_pierre_1 = factories.UserDocumentAccessFactory(
user=pierre_1, document=document_1, created_at=last_week
)
doc_1_pierre_1.created_at = last_week
doc_1_pierre_1.save()
doc_2_pierre_2 = factories.UserDocumentAccessFactory(
user=pierre_2, document=document_2
)
doc_2_pierre_2.created_at = last_month
doc_2_pierre_2.save()
doc_3_pierre_2 = factories.UserDocumentAccessFactory(
user=pierre_2, document=document_3
)
doc_3_pierre_2.created_at = yesterday
doc_3_pierre_2.save()
shared_map = utils.users_sharing_documents_with(user)
assert shared_map == {
pierre_1.id: last_week,
pierre_2.id: yesterday,
}

View File

@@ -59,6 +59,10 @@ urlpatterns = [
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
include(thread_related_router.urls),
),
path(
"user-reconciliations/<str:user_type>/<uuid:confirmation_id>/",
viewsets.ReconciliationConfirmView.as_view(),
),
]
),
),

View File

@@ -1,13 +1,21 @@
"""Utils for the core app."""
import base64
import logging
import re
import time
from collections import defaultdict
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
import pycrdt
from bs4 import BeautifulSoup
from core import enums
from core import enums, models
logger = logging.getLogger(__name__)
def get_ancestor_to_descendants_map(paths, steplen):
@@ -96,3 +104,46 @@ def extract_attachments(content):
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result

View File

@@ -3,8 +3,8 @@
"default": {
"logo": {
"src": "/assets/icon-docs.svg",
"width": "54px",
"alt": "Docs Logo",
"style": { "width": "54px", "height": "auto" },
"withTitle": true
},
"externalLinks": [
@@ -125,5 +125,38 @@
}
}
}
},
"home": {
"with-proconnect": false,
"icon-banner": {
"src": "/assets/icon-docs.svg",
"style": {
"width": "64px",
"height": "auto"
},
"alt": ""
}
},
"header": {
"logo": {},
"icon": {
"src": "/assets/icon-docs.svg",
"style": {
"width": "32px",
"height": "auto"
},
"alt": "",
"withTitle": true
}
},
"favicon": {
"light": {
"href": "/assets/favicon-light.png",
"type": "image/png"
},
"dark": {
"href": "/assets/favicon-dark.png",
"type": "image/png"
}
}
}

View File

@@ -169,6 +169,11 @@ class Base(Configuration):
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
AWS_S3_SIGNATURE_VERSION = values.Value(
"s3v4",
environ_name="AWS_S3_SIGNATURE_VERSION",
environ_prefix=None,
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
@@ -837,6 +842,11 @@ class Base(Configuration):
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
API_USERS_SEARCH_QUERY_MIN_LENGTH = values.PositiveIntegerValue(
default=3,
environ_name="API_USERS_SEARCH_QUERY_MIN_LENGTH",
environ_prefix=None,
)
# Content Security Policy
# See https://content-security-policy.com/ for more information.
@@ -870,6 +880,11 @@ class Base(Configuration):
),
}
# User accounts management
USER_RECONCILIATION_FORM_URL = values.Value(
None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False,

View File

@@ -192,10 +192,10 @@ endobj
(react-pdf)
endobj
55 0 obj
(D:20260128100716Z)
(D:20260210135720Z)
endobj
56 0 obj
(chromium-8039-0-doc-export-override-content)
(chromium-4728-0-doc-export-override-content)
endobj
52 0 obj
<<
@@ -216,7 +216,7 @@ endobj
58 0 obj
<<
/Type /FontDescriptor
/FontName /FDAZSC+Inter18pt-Regular
/FontName /XWNEXS+Inter18pt-Regular
/Flags 4
/FontBBox [-742.1875 -323.242187 2579.589844 1109.375]
/ItalicAngle 0
@@ -232,7 +232,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /FDAZSC+Inter18pt-Regular
/BaseFont /XWNEXS+Inter18pt-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -247,7 +247,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /FDAZSC+Inter18pt-Regular
/BaseFont /XWNEXS+Inter18pt-Regular
/Encoding /Identity-H
/DescendantFonts [59 0 R]
/ToUnicode 60 0 R
@@ -256,7 +256,7 @@ endobj
62 0 obj
<<
/Type /FontDescriptor
/FontName /UEJHFC+Inter18pt-Bold
/FontName /QGXPNV+Inter18pt-Bold
/Flags 4
/FontBBox [-790.527344 -334.472656 2580.566406 1114.746094]
/ItalicAngle 0
@@ -272,7 +272,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /UEJHFC+Inter18pt-Bold
/BaseFont /QGXPNV+Inter18pt-Bold
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -287,7 +287,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /UEJHFC+Inter18pt-Bold
/BaseFont /QGXPNV+Inter18pt-Bold
/Encoding /Identity-H
/DescendantFonts [63 0 R]
/ToUnicode 64 0 R
@@ -296,7 +296,7 @@ endobj
66 0 obj
<<
/Type /FontDescriptor
/FontName /EUMTON+Inter18pt-Italic
/FontName /SLYFFZ+Inter18pt-Italic
/Flags 68
/FontBBox [-747.558594 -323.242187 2595.703125 1109.375]
/ItalicAngle -9.398804
@@ -312,7 +312,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /EUMTON+Inter18pt-Italic
/BaseFont /SLYFFZ+Inter18pt-Italic
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -327,7 +327,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /EUMTON+Inter18pt-Italic
/BaseFont /SLYFFZ+Inter18pt-Italic
/Encoding /Identity-H
/DescendantFonts [67 0 R]
/ToUnicode 68 0 R
@@ -336,7 +336,7 @@ endobj
70 0 obj
<<
/Type /FontDescriptor
/FontName /HIJACG+GeistMono-Regular
/FontName /GPERZO+GeistMono-Regular
/Flags 5
/FontBBox [-1738 -247 654 1012]
/ItalicAngle 0
@@ -352,7 +352,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /HIJACG+GeistMono-Regular
/BaseFont /GPERZO+GeistMono-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -367,7 +367,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /HIJACG+GeistMono-Regular
/BaseFont /GPERZO+GeistMono-Regular
/Encoding /Identity-H
/DescendantFonts [71 0 R]
/ToUnicode 72 0 R
@@ -376,7 +376,7 @@ endobj
74 0 obj
<<
/Type /FontDescriptor
/FontName /IKVFNP+Inter18pt-BoldItalic
/FontName /CNJFYA+Inter18pt-BoldItalic
/Flags 68
/FontBBox [-795.898437 -334.472656 2596.191406 1114.746094]
/ItalicAngle -9.398804
@@ -392,7 +392,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /IKVFNP+Inter18pt-BoldItalic
/BaseFont /CNJFYA+Inter18pt-BoldItalic
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -407,7 +407,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /IKVFNP+Inter18pt-BoldItalic
/BaseFont /CNJFYA+Inter18pt-BoldItalic
/Encoding /Identity-H
/DescendantFonts [75 0 R]
/ToUnicode 76 0 R
@@ -1403,7 +1403,7 @@ trailer
/Size 87
/Root 3 0 R
/Info 52 0 R
/ID [<2f4ec8da7e87471807031f721b6c9ac2> <2f4ec8da7e87471807031f721b6c9ac2>]
/ID [<4d0627755c809232c991979db9766911> <4d0627755c809232c991979db9766911>]
>>
startxref
101726

View File

@@ -140,26 +140,6 @@ test.describe('Config', () => {
).toBeAttached();
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
await overrideConfig(page, {
theme_customization: {
translations: {
en: {
translation: {
Docs: 'MyCustomDocs',
},
},
},
},
});
await page.goto('/');
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
@@ -172,11 +152,7 @@ test.describe('Config', () => {
expect(response.ok()).toBeTruthy();
const json = (await response.json()) as typeof CONFIG;
const { theme_customization, ...configApi } = json;
expect(theme_customization).toBeDefined();
const { theme_customization: _, ...CONFIG_LEFT } = CONFIG;
expect(configApi).toStrictEqual(CONFIG_LEFT);
expect(json).toStrictEqual(CONFIG);
});
});
@@ -186,14 +162,24 @@ test.describe('Config: Not logged', () => {
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
await page.goto('/');
await expect(
page.getByText('Collaborative writing, Simplified.'),
).toHaveCSS('font-family', /Roboto/i, {
timeout: 10000,
});
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
});
await page.goto('/');
const header = page.locator('header').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(
page.getByText('Collaborative writing, Simplified.'),
).toHaveCSS('font-family', /Marianne/i, {
timeout: 10000,
});
});
});

View File

@@ -976,7 +976,10 @@ test.describe('Doc Editor', () => {
await expect(pdfBlock).toBeVisible();
// Try with invalid PDF first
await page.getByText(/Add (PDF|file)/).click();
await page
.getByText(/Add (PDF|file)/)
.first()
.click();
await page.locator('[data-test="embed-tab"]').click();
@@ -994,7 +997,6 @@ test.describe('Doc Editor', () => {
// Now with a valid PDF
await page.getByText(/Add (PDF|file)/).click();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download');
await page.getByText(/Upload (PDF|file)/).click();
const fileChooser = await fileChooserPromise;
@@ -1003,24 +1005,16 @@ test.describe('Doc Editor', () => {
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
const pdfEmbed = page
.locator('.--docs--editor-container embed.bn-visual-media')
const pdfIframe = page
.locator('.--docs--editor-container iframe.bn-visual-media')
.first();
// Check src of pdf
expect(await pdfEmbed.getAttribute('src')).toMatch(
expect(await pdfIframe.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
);
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
// Check download with original filename
await pdfBlock.click();
await page.locator('[data-test="downloadfile"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
await expect(pdfIframe).toHaveAttribute('role', 'presentation');
});
test('it preserves text when switching between mobile and desktop views', async ({

View File

@@ -1,19 +1,18 @@
import fs from 'fs';
import path from 'path';
import { Download, Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';
import {
BrowserName,
TestLanguage,
createDoc,
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { comparePDFWithAssetFolder, overrideDocContent } from './utils-export';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -33,7 +32,9 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
page.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
@@ -306,10 +307,63 @@ test.describe('Doc Export', () => {
expect(pdfString).toContain('/Lang (fr)');
});
test('it exports the doc to PDF with PRINT feature and checks regressions', async ({
page,
browserName,
}, testInfo) => {
// PDF Binary comparison is different depending on the browser used
// We only run this test on Chromium to avoid having to maintain
// multiple sets of PDF fixtures
if (browserName !== 'chromium') {
test.skip();
}
await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Print' }).click();
await page.getByRole('button', { name: 'Print' }).click();
await expect(page.locator('#print-only-content-styles')).toBeAttached();
await page.emulateMedia({ media: 'print' });
const pdfBuffer = await page.pdf({
printBackground: true,
preferCSSPageSize: true,
format: 'A4',
scale: 1,
});
// If we need to update the PDF regression fixture, uncomment the line below
// await savePDFToAssetFolder(
// pdfBuffer,
// 'doc-export-PDF-browser-regressions.pdf',
// );
// Assert the generated PDF matches the initial PDF regression fixture
await comparePDFWithAssetFolder({
originPdfBuffer: pdfBuffer,
filename: 'doc-export-PDF-browser-regressions.pdf',
compareTextContent: false,
comparePixel: false,
testInfo,
});
await expect(page.locator('#print-only-content-styles')).not.toBeAttached();
});
test('it exports the doc to PDF and checks regressions', async ({
page,
browserName,
}) => {
}, testInfo) => {
// PDF Binary comparison is different depending on the browser used
// We only run this test on Chromium to avoid having to maintain
// multiple sets of PDF fixtures
@@ -325,10 +379,6 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
@@ -338,152 +388,16 @@ test.describe('Doc Export', () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
// If we need to update the PDF regression fixture, uncomment the line below
//await savePDFToAssetFolder(download);
//await savePDFToAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
await comparePDFWithAssetFolder(download);
await comparePDFWithAssetFolder({
originPdfBuffer: pdfBuffer,
filename: 'doc-export-regressions.pdf',
testInfo,
});
});
});
export const savePDFToAssetFolder = async (download: Download) => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`);
fs.writeFileSync(pdfPath, pdfBuffer);
};
export const comparePDFWithAssetFolder = async (download: Download) => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
// Load reference PDF for comparison
const referencePdfPath = path.join(
__dirname,
'assets',
'doc-export-regressions.pdf',
);
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
// Parse both PDFs
const generatedPdf = new PDFParse({ data: pdfBuffer });
const referencePdf = new PDFParse({ data: referencePdfBuffer });
const [generatedInfo, referenceInfo] = await Promise.all([
generatedPdf.getInfo(),
referencePdf.getInfo(),
]);
const [generatedScreenshot, referenceScreenshot] = await Promise.all([
generatedPdf.getScreenshot(),
referencePdf.getScreenshot(),
]);
generatedScreenshot.pages[0].data;
const [generatedText, referenceText] = await Promise.all([
generatedPdf.getText(),
referencePdf.getText(),
]);
// Compare page count
expect(generatedInfo.total).toBe(referenceInfo.total);
// Compare text content
expect(generatedText.text).toBe(referenceText.text);
// Compare screenshots page by page
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
const genPage = generatedScreenshot.pages[i];
const refPage = referenceScreenshot.pages[i];
expect(genPage.width).toBe(refPage.width);
expect(genPage.height).toBe(refPage.height);
try {
expect(genPage.data).toStrictEqual(refPage.data);
} catch {
throw new Error(`PDF page ${i + 1} screenshot does not match reference.`);
}
}
};
/**
* Override the document content API response to use a test content
* This test content contains many blocks to facilitate testing
* @param page
*/
export const overrideDocContent = async ({
page,
browserName,
}: {
page: Page;
browserName: BrowserName;
}) => {
// Override content prop with assets/base-content-test-pdf.txt
await page.route(/\**\/documents\/\**/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
const response = await route.fetch();
const json = await response.json();
json.content = fs.readFileSync(
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
'utf-8',
);
void route.fulfill({
response,
body: JSON.stringify(json),
});
} else {
await route.continue();
}
});
const [randomDoc] = await createDoc(
page,
'doc-export-override-content',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.waitForTimeout(1000);
// Add Image SVG
await page.keyboard.press('Enter');
const { suggestionMenu } = await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".svg"]')
.first();
await expect(image).toBeVisible();
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// Add Image PNG
await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPNGPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooserPNG = await fileChooserPNGPromise;
await fileChooserPNG.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const imagePng = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".png"]')
.first();
await expect(imagePng).toBeVisible();
await page.waitForTimeout(1000);
return randomDoc;
};

View File

@@ -7,7 +7,12 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { mockedAccesses, mockedInvitations } from './utils-share';
import {
connectOtherUserToDoc,
mockedAccesses,
mockedInvitations,
updateShareLink,
} from './utils-share';
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -52,13 +57,54 @@ test.describe('Doc Header', () => {
).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
test('it updates the title doc and check the broadcast', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-title-update',
browserName,
1,
);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
const docUrl = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
docUrl,
browserName,
withoutSignIn: true,
docTitle,
});
// Wait for other page to sync
await page.waitForTimeout(1000);
await page.keyboard.press('Escape');
const elTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(elTitle).toBeVisible();
await elTitle.fill('Hello World');
await elTitle.blur();
await verifyDocName(page, 'Hello World');
// Wait for other page to sync
await page.waitForTimeout(1000);
// Check other user page
await verifyDocName(otherPage, 'Hello World');
const elTitleOther = otherPage.getByRole('textbox', {
name: 'Document title',
});
await elTitleOther.fill('Hello Other World');
await elTitleOther.blur();
// Check first user page
await verifyDocName(page, 'Hello Other World');
await cleanup();
});
test('it updates the title doc adding a leading emoji', async ({

View File

@@ -45,7 +45,7 @@ test.describe('Document search', () => {
const listSearch = page.getByRole('listbox').getByRole('group');
const rowdoc = listSearch.getByRole('option').first();
await expect(rowdoc.getByText('keyboard_return')).toBeVisible();
await expect(rowdoc.getByText(/seconds? ago/)).toBeVisible();
await expect(rowdoc.getByText(/just now/)).toBeVisible();
await expect(
listSearch.getByRole('option').getByText(doc1Title),

View File

@@ -56,14 +56,13 @@ test.describe('Footer', () => {
test('checks the footer is correctly overrided', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
footer: {
default: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
style: { width: '220px', height: 'auto' },
},
externalLinks: [
{

View File

@@ -34,10 +34,15 @@ test.describe('Header', () => {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
icon: {
src: '/assets/icon-docs-v2.svg',
style: {
width: '100px',
height: 'auto',
},
alt: '',
withTitle: false,
'data-testid': 'custom-testid-docs',
},
},
},
@@ -46,19 +51,11 @@ test.describe('Header', () => {
const header = page.locator('header').first();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.locator('h1').getByText('Docs')).toHaveCSS(
'font-family',
/Marianne/i,
await expect(header.getByTestId('custom-testid-docs')).toHaveAttribute(
'src',
'/assets/icon-docs-v2.svg',
);
await expect(
header.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(header.getByText('English')).toBeVisible();
await expect(header.locator('h1')).toBeHidden();
});
test('checks a custom waffle', async ({ page }) => {
@@ -146,32 +143,6 @@ test.describe('Header', () => {
});
});
test.describe('Header mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('it checks the header when mobile with DSFR theme', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
},
},
},
});
await page.goto('/');
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
});
});
test.describe('Header: Log out', () => {
test.use({ storageState: { cookies: [], origins: [] } });

View File

@@ -8,6 +8,7 @@ test.beforeEach(async ({ page }) => {
test.describe('Home page', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks all the elements are visible', async ({ page }) => {
await page.goto('/docs/');
@@ -90,21 +91,44 @@ test.describe('Home page', () => {
],
},
},
header: {
logo: {
src: '/assets/logo-gouv.svg',
alt: 'Gouvernement Logo',
style: { width: '110px', height: 'auto' },
},
icon: {
src: '/assets/icon-docs-v2.svg',
style: {
width: '100px',
height: 'auto',
},
alt: '',
withTitle: false,
},
},
home: {
'with-proconnect': true,
'icon-banner': {
src: '/assets/icon-docs.svg',
style: {
width: '64px',
height: 'auto',
},
alt: '',
},
},
},
});
await page.goto('/docs/');
// Wait for the page to be fully loaded and responsive store to be initialized
await page.waitForLoadState('domcontentloaded');
// Wait a bit more for the responsive store to be initialized
await page.waitForTimeout(500);
// Check header content
const header = page.locator('header').first();
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(header).toBeVisible({
timeout: 10000,
});
// Check for language picker - it should be visible on desktop
// Use a more flexible selector that works with both Header and HomeHeader
@@ -117,7 +141,6 @@ test.describe('Home page', () => {
header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
// Check the titles
const h2 = page.locator('h2');

View File

@@ -13,6 +13,32 @@ test.describe('Language', () => {
await page.goto('/');
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
await overrideConfig(page, {
theme_customization: {
translations: {
en: {
translation: {
Docs: 'MyCustomDocs',
},
},
},
header: {
logo: {},
icon: {
withTitle: true,
},
},
},
});
await page.goto('/');
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
test('checks language switching', async ({ page }) => {
const header = page.locator('header').first();
const languagePicker = header.locator('.--docs--language-picker-text');

View File

@@ -18,6 +18,20 @@ test.describe('Left panel desktop', () => {
await expect(page.getByTestId('home-button')).toBeVisible();
});
test('focuses main content after switching the docs filter', async ({
page,
}) => {
await page.goto('/');
const myDocsLink = page.getByRole('link', { name: 'My docs' });
await myDocsLink.focus();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/target=my_docs/);
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
});
test('checks resize handle is present and functional on document page', async ({
page,
browserName,

View File

@@ -1,10 +1,16 @@
import { Locator, Page, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { Locator, Page, TestInfo, expect } from '@playwright/test';
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_FEATURE_ENABLED: true,
API_USERS_SEARCH_QUERY_MIN_LENGTH: 3,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
@@ -28,7 +34,7 @@ export const CONFIG = {
POSTHOG_KEY: {},
SENTRY_DSN: null,
TRASHBIN_CUTOFF_DAYS: 30,
theme_customization: {},
theme_customization,
} as const;
export const overrideConfig = async (
@@ -388,3 +394,30 @@ export const clickInGridMenu = async (
.click();
await page.getByRole('menuitem', { name: textButton }).click();
};
export const writeReport = async (
testInfo: TestInfo,
filename: string,
attachName: string,
buffer: Buffer,
contentType: string,
) => {
const REPORT_DIRNAME = 'extra-report';
const REPORT_NAME = 'test-results';
const outDir = testInfo
? path.join(testInfo.outputDir, REPORT_DIRNAME, path.parse(filename).name)
: path.join(
process.cwd(),
REPORT_NAME,
REPORT_DIRNAME,
path.parse(filename).name,
);
fs.mkdirSync(outDir, { recursive: true });
const pathToFile = path.join(outDir, filename);
fs.writeFileSync(pathToFile, buffer);
await testInfo.attach(attachName, {
path: pathToFile,
contentType: contentType,
});
};

View File

@@ -0,0 +1,239 @@
import fs from 'fs';
import path from 'path';
import { Page, TestInfo, expect } from '@playwright/test';
import { PDFParse } from 'pdf-parse';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
import {
BrowserName,
createDoc,
verifyDocName,
writeReport,
} from './utils-common';
import { openSuggestionMenu } from './utils-editor';
/**
* Override the document content API response to use a test content
* This test content contains many blocks to facilitate testing
* @param page
*/
export const overrideDocContent = async ({
page,
browserName,
}: {
page: Page;
browserName: BrowserName;
}) => {
// Override content prop with assets/base-content-test-pdf.txt
await page.route(/\**\/documents\/\**/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
const response = await route.fetch();
const json = await response.json();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
json.content = fs.readFileSync(
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
'utf-8',
);
void route.fulfill({
response,
body: JSON.stringify(json),
});
} else {
await route.continue();
}
});
const [randomDoc] = await createDoc(
page,
'doc-export-override-content',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.waitForTimeout(1000);
// Add Image SVG
await page.keyboard.press('Enter');
const { suggestionMenu } = await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".svg"]')
.first();
await expect(image).toBeVisible();
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// Add Image PNG
await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPNGPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooserPNG = await fileChooserPNGPromise;
await fileChooserPNG.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const imagePng = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".png"]')
.first();
await expect(imagePng).toBeVisible();
await page.waitForTimeout(1000);
return randomDoc;
};
export const savePDFToAssetFolder = async (
pdfBuffer: Buffer,
filename: string,
) => {
const pdfPath = path.join(__dirname, 'assets', filename);
fs.writeFileSync(pdfPath, pdfBuffer);
};
interface ComparePDFWithAssetFolderOptions {
originPdfBuffer: Buffer;
filename: string;
compareTextContent?: boolean;
comparePixel?: boolean;
testInfo?: TestInfo;
}
export const comparePDFWithAssetFolder = async ({
originPdfBuffer,
filename,
compareTextContent = true,
comparePixel = true,
testInfo,
}: ComparePDFWithAssetFolderOptions) => {
// Load reference PDF for comparison
const referencePdfPath = path.join(__dirname, 'assets', filename);
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
// Parse both PDFs
const generatedPdf = new PDFParse({ data: originPdfBuffer });
const referencePdf = new PDFParse({ data: referencePdfBuffer });
const [generatedInfo, referenceInfo] = await Promise.all([
generatedPdf.getInfo(),
referencePdf.getInfo(),
]);
const [generatedScreenshot, referenceScreenshot] = await Promise.all([
generatedPdf.getScreenshot(),
referencePdf.getScreenshot(),
]);
const [generatedText, referenceText] = await Promise.all([
generatedPdf.getText(),
referencePdf.getText(),
]);
// Compare page count
expect(generatedInfo.total).toBe(referenceInfo.total);
/*
Compare text content
We make this optional because text extraction from PDFs can vary
slightly between environments and PDF versions, leading to false negatives.
Particularly with emojis which can be represented differently when
exporting or parsing the PDF.
*/
if (compareTextContent) {
expect(generatedText.text).toBe(referenceText.text);
}
// Compare screenshots page by page
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
const genPage = generatedScreenshot.pages[i];
const refPage = referenceScreenshot.pages[i];
const genPng = PNG.sync.read(Buffer.from(genPage.data));
const refPng = PNG.sync.read(Buffer.from(refPage.data));
// Compare actual raster dimensions (integers)
expect(genPng.width).toBe(refPng.width);
expect(genPng.height).toBe(refPng.height);
if (!comparePixel) {
continue;
}
const diffPng = new PNG({ width: genPng.width, height: genPng.height });
const numDiffPixels = pixelmatch(
genPng.data,
refPng.data,
diffPng.data,
genPng.width,
genPng.height,
{ threshold: 0.1, includeAA: false },
);
const totalPixels = genPng.width * genPng.height;
const diffRatio = numDiffPixels / totalPixels;
const maxDiffRatio = 0.0005;
try {
expect(numDiffPixels).toBeLessThan(0.0005);
} catch {
if (testInfo) {
const pageNo = String(i + 1).padStart(2, '0');
await writeReport(
testInfo,
`generated.pdf`,
`pdf-generated`,
originPdfBuffer,
'application/pdf',
);
await writeReport(
testInfo,
`reference.pdf`,
`pdf-reference`,
referencePdfBuffer,
'application/pdf',
);
await writeReport(
testInfo,
`page-${pageNo}-diff.png`,
`page-${pageNo}-diff`,
PNG.sync.write(diffPng),
'image/png',
);
await writeReport(
testInfo,
`page-${pageNo}-generated.png`,
`page-${pageNo}-generated`,
PNG.sync.write(genPng),
'image/png',
);
await writeReport(
testInfo,
`page-${pageNo}-reference.png`,
`page-${pageNo}-reference`,
PNG.sync.write(refPng),
'image/png',
);
}
throw new Error(
`PDF visual regression: ${filename} page ${i + 1} diffRatio=${diffRatio.toFixed(6)} (${numDiffPixels} px) > ${maxDiffRatio}`,
);
}
}
};

View File

@@ -18,12 +18,15 @@
"@playwright/test": "1.57.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "workspace:^",
"eslint-plugin-docs": "*",
"typescript": "*"
},
"dependencies": {
"@types/pngjs": "6.0.5",
"convert-stream": "1.0.2",
"pdf-parse": "2.4.5"
"pdf-parse": "2.4.5",
"pixelmatch": "7.1.0",
"pngjs": "7.0.0"
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -19,24 +19,6 @@ const themeWhiteLabelLight = getUIKitThemesFromGlobals(whiteLabelGlobals, {
'2xs': '0.375rem',
},
},
components: {
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'home-proconnect': false,
icon: {
src: '/assets/icon-docs.svg',
width: '32px',
height: 'auto',
},
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
},
},
});
@@ -56,25 +38,6 @@ const themesDSFRLight = getUIKitThemesFromGlobals(dsfrGlobals, {
},
},
},
components: {
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
'home-proconnect': true,
icon: {
src: '/assets/icon-docs-dsfr.svg',
width: '32px',
height: 'auto',
},
favicon: {
ico: '/assets/favicon-dsfr.ico',
'png-light': '/assets/favicon-dsfr.png',
'png-dark': '/assets/favicon-dark-dsfr.png',
},
},
},
});

View File

@@ -50,7 +50,7 @@ const nextConfig = {
{
from: path.resolve(
__dirname,
'./node_modules/emoji-datasource-apple/img/apple/64',
'../../node_modules/emoji-datasource-apple/img/apple/64',
),
to: path.resolve(__dirname, 'public/assets/fonts/emoji'),
force: true,

View File

@@ -89,7 +89,7 @@
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint-plugin-docs": "workspace:^",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.4.0",
"node-fetch": "2.7.0",
@@ -103,5 +103,5 @@
"webpack": "5.104.1",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "pnpm@10.28.2"
"packageManager": "yarn@1.22.22"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,34 @@
<svg
width="100"
height="40"
viewBox="0 0 100 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M25.6305 32.8312C26.7983 32.5038 27.9166 31.9062 28.6505 30.8503C29.3749 29.8163 29.5789 28.5047 29.5789 27.2425V8.75099C29.5789 8.42358 29.5611 8.09557 29.5216 7.77148C30.1016 7.99961 30.5486 8.37658 30.8626 8.90239C31.2331 9.50024 31.4184 10.2876 31.4184 11.2643V30.0464C31.4184 31.3684 31.0942 32.3578 30.4458 33.0146C29.7974 33.6714 28.8207 33.9998 27.5155 33.9998H20.4209C20.5889 33.9704 20.7574 33.9401 20.9262 33.909C22.4067 33.6444 23.9713 33.2854 25.6185 32.8346L25.6305 32.8312Z"
fill="#C83F49"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.58203 29.655V10.8477C8.58203 9.70251 8.88938 8.83519 9.50408 8.24575C10.1272 7.65631 10.9524 7.33212 11.9797 7.27318C13.4954 7.18055 14.9311 7.05425 16.2868 6.89425C17.6425 6.72584 18.9393 6.53217 20.1771 6.31324C21.4234 6.0943 22.6359 5.85011 23.8148 5.58065C25.0274 5.29435 25.9578 5.4375 26.6062 6.0101C27.2546 6.58269 27.5788 7.49632 27.5788 8.75099V27.2425C27.5788 28.3456 27.3893 29.1666 27.0104 29.7055C26.6315 30.2529 25.9915 30.6528 25.0905 30.9054C23.4906 31.3433 21.9833 31.6886 20.5687 31.9412C19.154 32.2022 17.7731 32.4001 16.4258 32.5348C15.0785 32.6696 13.6975 32.7748 12.2829 32.8506C11.1124 32.918 10.203 32.6738 9.5546 32.118C8.90622 31.5707 8.58203 30.7497 8.58203 29.655ZM13.2087 14.2624C15.0635 14.1444 16.7632 13.9631 18.3075 13.7183C18.6822 13.6572 19.0564 13.5936 19.4291 13.5276C19.8192 13.4585 20.1013 13.1186 20.1013 12.7234C20.1013 12.2115 19.638 11.8261 19.135 11.9119C18.846 11.9612 18.5555 12.0091 18.2635 12.0556C16.7346 12.2992 15.0452 12.48 13.1952 12.5977C12.9182 12.6156 12.6978 12.7019 12.5561 12.8739C12.4221 13.0366 12.3564 13.2323 12.3564 13.4553C12.3564 13.6821 12.433 13.8795 12.5857 14.0418L12.5878 14.0439C12.7534 14.2095 12.9637 14.2811 13.2087 14.2624ZM13.208 18.456C15.0631 18.338 16.763 18.1566 18.3075 17.9119C19.8588 17.6589 21.3936 17.3638 22.9112 17.0266C23.2191 16.9581 23.4498 16.8503 23.5652 16.683C23.6786 16.5221 23.7347 16.3376 23.7347 16.1332C23.7347 15.9026 23.6469 15.704 23.476 15.5426C23.2921 15.3689 23.0348 15.3284 22.7304 15.3911L22.7285 15.3915C21.2823 15.7194 19.794 16.0053 18.2635 16.2492C16.7346 16.4928 15.0452 16.6735 13.1952 16.7913C12.9182 16.8091 12.6978 16.8954 12.5561 17.0675C12.4228 17.2294 12.3564 17.4205 12.3564 17.6363C12.3564 17.8703 12.4321 18.0723 12.5856 18.2354L12.59 18.2396C12.755 18.3949 12.9632 18.4655 13.2055 18.4562L13.208 18.456ZM13.2085 22.6494C15.0634 22.5229 16.7631 22.3374 18.3075 22.0927C19.8589 21.8482 21.3934 21.5573 22.9112 21.22C23.2199 21.1514 23.4508 21.0391 23.566 20.8627C23.6783 20.7029 23.7347 20.5233 23.7347 20.3266C23.7347 20.0961 23.6469 19.8974 23.476 19.7361C23.2921 19.5623 23.0348 19.5218 22.7304 19.5845L22.729 19.5848C21.2827 19.9043 19.7942 20.1861 18.2635 20.43C16.7345 20.6736 15.045 20.8586 13.1949 20.9847C12.918 21.0026 12.6977 21.0889 12.5561 21.2609C12.4228 21.4228 12.3564 21.6139 12.3564 21.8297C12.3564 22.0637 12.4321 22.2658 12.5856 22.4289L12.59 22.433C12.755 22.5883 12.9632 22.6589 13.2055 22.6496L13.2085 22.6494ZM18.3075 26.257C16.7632 26.5018 15.0635 26.6831 13.2087 26.8012C12.9637 26.8198 12.7534 26.7482 12.5878 26.5826L12.5857 26.5805C12.433 26.4182 12.3564 26.2208 12.3564 25.9941C12.3564 25.771 12.4221 25.5753 12.5561 25.4126C12.6978 25.2406 12.9183 25.1543 13.1953 25.1364C15.0453 25.0187 16.7346 24.838 18.2635 24.5943C18.5555 24.5478 18.846 24.4999 19.135 24.4506C19.638 24.3648 20.1013 24.7503 20.1013 25.2621C20.1013 25.6573 19.8192 25.9972 19.4291 26.0663C19.0564 26.1323 18.6822 26.1959 18.3075 26.257Z"
fill="#2845C1"
/>
<path
d="M41.2 27.95C41.0895 27.95 41 27.8605 41 27.75V12.2C41 12.0895 41.0895 12 41.2 12H47.2205C48.4813 12 49.6282 12.2127 50.6611 12.638C51.6941 13.0633 52.5827 13.6482 53.3271 14.3925C54.0866 15.1216 54.6638 15.9647 55.0588 16.9217C55.4689 17.8787 55.674 18.8965 55.674 19.975C55.674 21.0535 55.4689 22.0713 55.0588 23.0283C54.6638 23.9853 54.0866 24.836 53.3271 25.5803C52.5827 26.3094 51.6941 26.8867 50.6611 27.312C49.6282 27.7373 48.4813 27.95 47.2205 27.95H41.2ZM47.2433 14.5292H44.0026C43.8922 14.5292 43.8026 14.6188 43.8026 14.7292V25.2208C43.8026 25.3312 43.8922 25.4208 44.0026 25.4208H47.2433C48.0484 25.4208 48.7851 25.2841 49.4535 25.0106C50.1371 24.722 50.7219 24.3271 51.208 23.8258C51.7093 23.3245 52.0966 22.7473 52.3701 22.0941C52.6435 21.4409 52.7802 20.7345 52.7802 19.975C52.7802 19.2155 52.6435 18.5091 52.3701 17.8559C52.0966 17.1875 51.7093 16.6103 51.208 16.1242C50.7219 15.6229 50.1371 15.2356 49.4535 14.9621C48.7851 14.6735 48.0484 14.5292 47.2433 14.5292Z"
fill="#2845C1"
/>
<path
d="M63.3939 25.6031C63.9104 25.6031 64.3889 25.5044 64.8294 25.3069C65.2699 25.0943 65.6497 24.8132 65.9687 24.4639C66.3029 24.1145 66.5611 23.7119 66.7434 23.2562C66.9257 22.7853 67.0168 22.284 67.0168 21.7524C67.0168 21.0232 66.8573 20.37 66.5383 19.7928C66.2193 19.2155 65.7864 18.7598 65.2395 18.4256C64.6927 18.0763 64.0775 17.9016 63.3939 17.9016C62.8622 17.9016 62.3686 18.0003 61.9128 18.1978C61.4723 18.3953 61.085 18.6687 60.7508 19.0181C60.4318 19.3674 60.1811 19.7776 59.9988 20.2485C59.8166 20.7194 59.7254 21.2207 59.7254 21.7524C59.7254 22.4663 59.8849 23.1195 60.2039 23.7119C60.5229 24.2892 60.9558 24.7525 61.5027 25.1019C62.0647 25.436 62.6951 25.6031 63.3939 25.6031ZM63.3711 15.5546C64.2977 15.5546 65.1408 15.7141 65.9003 16.0331C66.675 16.3521 67.3358 16.8003 67.8827 17.3775C68.4447 17.9395 68.8701 18.6003 69.1587 19.3599C69.4625 20.1042 69.6144 20.9017 69.6144 21.7524C69.6144 22.603 69.4625 23.4081 69.1587 24.1676C68.8701 24.912 68.4447 25.5728 67.8827 26.15C67.3358 26.712 66.675 27.1526 65.9003 27.4716C65.1408 27.7906 64.2977 27.9501 63.3711 27.9501C62.4445 27.9501 61.5938 27.7906 60.8191 27.4716C60.0596 27.1526 59.3988 26.712 58.8368 26.15C58.2747 25.5728 57.8418 24.912 57.538 24.1676C57.2494 23.4081 57.1051 22.603 57.1051 21.7524C57.1051 20.9017 57.2494 20.1042 57.538 19.3599C57.8418 18.6003 58.2747 17.9395 58.8368 17.3775C59.3988 16.8003 60.0596 16.3521 60.8191 16.0331C61.5938 15.7141 62.4445 15.5546 63.3711 15.5546Z"
fill="#2845C1"
/>
<path
d="M77.2852 15.5546C78.3333 15.5546 79.2675 15.7673 80.0878 16.1926C80.8611 16.5723 81.5043 17.082 82.0173 17.7219C82.086 17.8076 82.0681 17.9322 81.9806 17.9986L80.3269 19.2531C80.2346 19.3232 80.1027 19.2999 80.0326 19.2076C79.7582 18.8466 79.4045 18.548 78.9713 18.3117C78.4852 18.0383 77.9156 17.9016 77.2624 17.9016C76.7307 17.9016 76.237 18.0003 75.7813 18.1978C75.3408 18.3953 74.961 18.6687 74.642 19.0181C74.323 19.3674 74.0724 19.7776 73.8901 20.2485C73.7078 20.7042 73.6167 21.2055 73.6167 21.7524C73.6167 22.4815 73.7762 23.1423 74.0952 23.7347C74.4142 24.3119 74.8471 24.7677 75.394 25.1019C75.9408 25.436 76.5788 25.6031 77.308 25.6031C77.9308 25.6031 78.4852 25.4664 78.9713 25.193C79.4045 24.9567 79.7582 24.6581 80.0326 24.2971C80.1027 24.2048 80.2346 24.1815 80.3269 24.2516L81.979 25.5049C82.0671 25.5717 82.0845 25.6976 82.0145 25.7831C81.5019 26.4095 80.8597 26.9191 80.0878 27.3121C79.2675 27.7374 78.3333 27.9501 77.2852 27.9501C75.994 27.9501 74.8775 27.669 73.9357 27.107C72.9939 26.5298 72.2647 25.7702 71.7483 24.8284C71.247 23.8866 70.9963 22.8613 70.9963 21.7524C70.9963 20.9169 71.1406 20.127 71.4293 19.3826C71.7179 18.6383 72.1356 17.9775 72.6825 17.4003C73.2293 16.823 73.8901 16.3749 74.6648 16.0559C75.4395 15.7217 76.313 15.5546 77.2852 15.5546Z"
fill="#2845C1"
/>
<path
d="M85.9175 18.9041C85.9175 19.2687 86.0466 19.5725 86.3049 19.8156C86.5783 20.0434 86.9277 20.2409 87.353 20.408C87.7783 20.5751 88.2265 20.7574 88.6974 20.9549C89.1835 21.1371 89.6392 21.365 90.0645 21.6384C90.4898 21.8967 90.8316 22.246 91.0899 22.6866C91.3633 23.1119 91.5 23.6512 91.5 24.3044C91.5 25.0791 91.3101 25.7399 90.9304 26.2867C90.5658 26.8184 90.0797 27.2285 89.4721 27.5171C88.8644 27.8058 88.1961 27.9501 87.4669 27.9501C86.4795 27.9501 85.6213 27.7678 84.8921 27.4032C84.2203 27.0533 83.6259 26.5938 83.1088 26.0246C83.0352 25.9436 83.0442 25.8185 83.1263 25.7462L84.5297 24.5112C84.6154 24.4358 84.7464 24.4475 84.821 24.534C85.1512 24.917 85.5168 25.2354 85.9175 25.4892C86.3732 25.7778 86.8821 25.9221 87.4441 25.9221C87.9758 25.9221 88.3784 25.7854 88.6518 25.512C88.9404 25.2234 89.0847 24.874 89.0847 24.4639C89.0847 24.0841 88.948 23.7803 88.6746 23.5524C88.4163 23.3246 88.0745 23.1271 87.6492 22.96C87.2239 22.7929 86.7682 22.6182 86.2821 22.4359C85.8112 22.2384 85.363 22.003 84.9377 21.7296C84.5124 21.4561 84.163 21.1068 83.8896 20.6814C83.6313 20.2409 83.5022 19.6789 83.5022 18.9953C83.5022 18.3573 83.6693 17.78 84.0035 17.2636C84.3377 16.7319 84.801 16.3142 85.3934 16.0104C86.001 15.7065 86.6922 15.5546 87.4669 15.5546C88.3024 15.5546 89.0771 15.7369 89.7911 16.1015C90.4556 16.4338 90.9877 16.8481 91.3872 17.3444C91.4528 17.4258 91.4385 17.5439 91.359 17.6117L89.9551 18.8097C89.8666 18.8853 89.7328 18.8692 89.6595 18.7789C89.4001 18.4596 89.1022 18.19 88.7657 17.9699C88.3708 17.7117 87.9378 17.5826 87.4669 17.5826C87.1327 17.5826 86.8441 17.6433 86.6011 17.7649C86.3732 17.8864 86.1985 18.0459 86.077 18.2434C85.9707 18.4408 85.9175 18.6611 85.9175 18.9041Z"
fill="#2845C1"
/>
</svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -1,248 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 311.6 186.5"
style="enable-background: new 0 0 311.6 186.5"
xml:space="preserve"
>
<style type="text/css">
.st0 {
fill: #ffffff;
}
.st1 {
fill: #000091;
}
.st2 {
fill: #e1000f;
}
.st3 {
fill: #9c9b9b;
}
</style>
<g id="Fond">
<rect x="0" class="st0" width="311.6" height="186.5" />
</g>
<g id="Calque_1">
<path
id="Devise_Républicaine_1_"
d="M100.5,150.8c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6C99.1,151.8,99.9,150.8,100.5,150.8 M102,155.2
h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4
c0,1.3,0.6,2.1,1.6,2.1C99.8,158.2,101,157,102,155.2 M101.2,148.9l3-2.8v-0.3h-1.6l-1.9,3.2H101.2z M91.9,150.9h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V150.9z M89.9,150.6c0.2-0.6-0.2-0.9-0.5-0.9c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2
c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4c-0.4,0.6-1.1,1.2-1.7,1.3L89.9,150.6z M90.4,147.5c0.6,0,1-0.5,1-1
c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1C89.3,147,89.8,147.5,90.4,147.5 M76.6,157c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6
l1.7-4.4c0.8-0.9,2.3-1.9,3-1.9c0.5,0,0.4,0.4,0.1,0.9l-2.5,4.9c-0.2,0.5,0.1,1.1,0.6,1.1c1.2,0,2.7-1.1,3.3-2.7h-0.4
c-0.4,0.6-1.1,1.2-1.7,1.3l2.2-4.4c0.3-0.6,0.4-1.1,0.4-1.5c0-0.7-0.4-1.2-1.2-1.2c-1.1,0-2.2,1.2-3.5,2.7v-1.2
c0-0.8-0.3-1.6-1-1.6c-0.5,0-0.9,0.4-1.3,1v0.2c0.8,0,1.2,1.2,0.6,2.4L76.6,157z M76.6,151.6c0.3-1,0.1-1.9-0.6-1.9
c-0.9,0-1.2,0.7-2.1,2.7v-1.2c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7H71c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7
c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6H76.6z M67,150.8c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C65.6,151.8,66.4,150.8,67,150.8 M68.5,155.2H68c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C66.3,158.2,67.5,157,68.5,155.2 M58.3,150.9h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V150.9z M50.5,155.7c0-1.9,2.1-4.5,3.3-4.5c0.3,0,0.5,0,0.7,0.1l-1.2,3.3c-0.7,0.9-1.8,1.9-2.3,1.9
C50.7,156.6,50.5,156.3,50.5,155.7 M57,149.3l-0.7,0l-0.7,0.7h-0.2c-3.6,0-6.6,4-6.6,6.9c0,0.8,0.5,1.3,1.3,1.3
c0.9,0,1.8-1.3,2.8-2.7l0,0.5c-0.1,1.4,0.3,2.2,1.1,2.2c0.9,0,1.7-1.4,2.3-2.7h-0.4c-0.4,0.6-0.8,1-1.1,1c-0.3,0-0.6-0.6,0-1.9
L57,149.3z M49.5,151.6c0.3-1,0.1-1.9-0.6-1.9c-0.9,0-1.2,0.7-2.1,2.7v-1.2c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7h0.4
c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6
H49.5z M37.5,157.9l0.2-0.5c-2.1-0.4-2.3-0.4-1.5-2.6l0.8-2.3h2.3c1,0,1,0.4,0.9,1.5h0.6l1.4-3.8h-0.6c-0.5,0.9-0.9,1.5-2,1.5h-2.3
l1.2-3.3c0.4-1,0.6-1.3,2-1.3h1c1.4,0,1.6,0.4,1.6,1.9h0.6l0.5-2.6h-8.6l-0.2,0.5c1.7,0.3,1.8,0.5,1,2.6l-1.9,5.1
c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H37.5z M79.7,131.4c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6C78.3,132.3,79.1,131.4,79.7,131.4 M81.2,135.7
h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4
c0,1.3,0.6,2.1,1.6,2.1C79,138.7,80.2,137.6,81.2,135.7 M80.3,129.4l3-2.8v-0.3h-1.6l-1.9,3.2H80.3z M71,131.4h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V131.4z M69.1,131.1c0.2-0.6-0.2-0.9-0.5-0.9c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2
c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4c-0.4,0.6-1.1,1.2-1.7,1.3L69.1,131.1z M69.5,128c0.6,0,1-0.5,1-1
c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1C68.5,127.6,68.9,128,69.5,128 M61.2,137.3l4.3-11.4l-0.1-0.2l-2.7,0.3v0.3l0.5,0.4
c0.5,0.4,0.3,0.7-0.1,1.9l-3.4,8.9c-0.2,0.5,0.1,1.1,0.6,1.1c1.2,0,2.6-1.1,3.2-2.7h-0.4C62.7,136.6,61.8,137.2,61.2,137.3
M53,136.3c0-1.9,2.1-4.5,3.3-4.5c0.3,0,0.5,0,0.7,0.1l-1.2,3.3c-0.7,0.9-1.8,1.9-2.3,1.9C53.1,137.1,53,136.8,53,136.3
M59.5,129.9l-0.7,0l-0.7,0.7H58c-3.6,0-6.6,4-6.6,6.9c0,0.8,0.5,1.3,1.3,1.3c0.9,0,1.8-1.3,2.8-2.7l0,0.5
c-0.1,1.4,0.3,2.2,1.1,2.2c0.9,0,1.7-1.4,2.3-2.7h-0.4c-0.4,0.6-0.8,1-1.1,1c-0.3,0-0.6-0.6,0-1.9L59.5,129.9z M43.7,140.2
c0-0.8,0.8-1.3,1.9-1.8c0.4,0.2,0.9,0.4,1.7,0.6c1.2,0.4,1.6,0.5,1.6,0.9c0,0.8-1.3,1.3-3.1,1.3C44.4,141.3,43.7,141,43.7,140.2
M46.9,135.2c-0.5,0-0.7-0.4-0.7-0.9c0-1.4,0.7-3.4,1.9-3.4c0.5,0,0.7,0.4,0.7,0.9C48.8,133.1,48,135.2,46.9,135.2 M50.3,139.4
c0-1-0.9-1.4-2.3-1.8c-1.2-0.4-1.8-0.5-1.8-0.9c0-0.3,0.3-0.7,0.8-1c2-0.1,3.4-1.9,3.4-3.5c0-0.3-0.1-0.6-0.2-0.9h1.7l0.3-0.9h-2.7
c-0.3-0.2-0.7-0.3-1.1-0.3c-2.2,0-3.6,1.9-3.6,3.4c0,1.2,0.7,1.9,1.7,2.1c-1,0.5-1.6,1-1.6,1.6c0,0.4,0.1,0.6,0.4,0.8
c-2.3,0.7-3.3,1.5-3.3,2.6c0,1.1,1.4,1.5,3.1,1.5C47.9,142.3,50.3,140.7,50.3,139.4 M39.4,132.8c1,0,1,0.4,0.9,1.5h0.6l1.4-3.8
h-0.6c-0.5,0.9-0.9,1.5-2,1.5h-2.3l1.1-3.1c0.4-1,0.6-1.2,2-1.2h1c1.4,0,1.6,0.4,1.6,1.8h0.6l0.5-2.6h-8.6l-0.2,0.5
c1.7,0.3,1.8,0.5,1,2.6l-1.9,5.1c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H41l1.7-2.7H42c-1.1,1-2.5,2-4.4,2c-2.5,0-2.3-0.1-1.5-2.4
l0.9-2.5H39.4z M40.6,126.2l3-2.1v-0.3h-1.8l-1.7,2.4H40.6z M78.7,111.9c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C77.3,112.8,78,111.9,78.7,111.9 M80.2,116.2h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C77.9,119.2,79.2,118.1,80.2,116.2 M79.3,110l3-2.8v-0.3h-1.6
l-1.9,3.2H79.3z M70.4,111.9h1.1l-2.3,6.2c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8
h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2l-1.5,0.2V111.9z M69.2,112.6c0.3-1,0.1-1.9-0.6-1.9c-0.9,0-1.2,0.7-2.1,2.7v-1.2
c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7h0.4c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7c-0.3,0.7,0,1.2,0.7,1.2
c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6H69.2z M59.7,111.9c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C58.3,112.8,59.1,111.9,59.7,111.9 M61.2,116.2h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C59,119.2,60.2,118.1,61.2,116.2 M50.6,118c-0.4,0-1-0.4-1-0.7
c0-0.1,0.2-0.6,0.4-1.2l0.7-1.9c0.7-0.9,1.9-1.8,2.5-1.8c0.4,0,0.7,0.2,0.7,0.8C53.9,114.9,52.3,118,50.6,118 M55.5,112.5
c0-1.3-0.5-1.7-1.3-1.7c-1.1,0-2.1,1.2-3.1,2.6l2.6-6.8l-0.1-0.2l-2.7,0.3v0.3l0.5,0.4c0.5,0.4,0.3,0.8-0.1,1.9l-2.8,7.2
c-0.2,0.5-0.5,1.2-0.5,1.3c0,0.7,1,1.4,1.9,1.4C52,119.2,55.5,115.4,55.5,112.5 M47,111.6c0.2-0.6-0.2-0.9-0.5-0.9
c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4
c-0.4,0.6-1.1,1.2-1.7,1.3L47,111.6z M47.5,108.5c0.6,0,1-0.5,1-1c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1
C46.4,108.1,46.9,108.5,47.5,108.5 M41.2,107.5h-5.7l-0.2,0.5c1.7,0.3,1.8,0.5,1,2.6l-1.8,5.1c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H40
l1.9-3.3h-0.7c-1.1,1.2-2.3,2.6-4.2,2.6c-1.4,0-1.6-0.2-0.8-2.4l1.8-5.1c0.8-2.1,1.1-2.3,3-2.6L41.2,107.5z"
/>
<path
d="M40.9,88.2c1,0,1.9-0.2,2.7-0.5c0.8-0.3,1.5-0.8,2.1-1.3v-3.5h-5.3v-3.8h9.4v8.8c-1,1.3-2.2,2.3-3.8,3.1s-3.3,1.2-5.2,1.2
c-1.7,0-3.2-0.3-4.6-0.9c-1.4-0.6-2.6-1.4-3.6-2.4c-1-1-1.8-2.1-2.3-3.5c-0.6-1.3-0.8-2.7-0.8-4.2c0-1.5,0.3-2.9,0.8-4.2
c0.5-1.3,1.3-2.5,2.2-3.5c1-1,2.1-1.8,3.5-2.4s2.8-0.9,4.5-0.9c1.9,0,3.5,0.4,5,1.2c1.5,0.8,2.7,1.8,3.7,3L46,77
c-0.6-0.8-1.3-1.5-2.3-2.1s-2-0.8-3.1-0.8c-1,0-1.9,0.2-2.7,0.5c-0.8,0.4-1.5,0.9-2.1,1.5c-0.6,0.6-1,1.4-1.4,2.2
c-0.3,0.9-0.5,1.8-0.5,2.8s0.2,1.9,0.5,2.8c0.3,0.9,0.8,1.6,1.4,2.2c0.6,0.6,1.4,1.1,2.2,1.5C39,88,39.9,88.2,40.9,88.2z M64,70.3
c1.6,0,3.1,0.3,4.4,0.9s2.5,1.4,3.5,2.4c1,1,1.7,2.1,2.2,3.5c0.5,1.3,0.8,2.7,0.8,4.2c0,1.5-0.3,2.9-0.8,4.2
c-0.5,1.3-1.3,2.5-2.2,3.5c-1,1-2.1,1.8-3.5,2.4s-2.8,0.9-4.4,0.9c-1.6,0-3.1-0.3-4.5-0.9s-2.5-1.4-3.5-2.4c-1-1-1.7-2.1-2.2-3.5
c-0.5-1.3-0.8-2.7-0.8-4.2c0-1.5,0.3-2.9,0.8-4.2c0.5-1.3,1.3-2.5,2.2-3.5c1-1,2.1-1.8,3.5-2.4S62.4,70.3,64,70.3z M64,88.2
c1,0,1.9-0.2,2.7-0.5c0.8-0.4,1.5-0.9,2.1-1.5c0.6-0.6,1-1.4,1.4-2.2s0.5-1.8,0.5-2.8s-0.2-1.9-0.5-2.8c-0.3-0.9-0.8-1.6-1.4-2.2
c-0.6-0.6-1.3-1.1-2.1-1.5c-0.8-0.4-1.7-0.5-2.7-0.5c-1,0-1.9,0.2-2.7,0.5c-0.8,0.4-1.5,0.9-2.1,1.5c-0.6,0.6-1,1.4-1.4,2.2
c-0.3,0.9-0.5,1.8-0.5,2.8s0.2,1.9,0.5,2.8c0.3,0.9,0.8,1.6,1.4,2.2c0.6,0.6,1.3,1.1,2.1,1.5C62.1,88,63,88.2,64,88.2z M91.1,83.8
V70.9h4.2v12.6c0,2.7-0.8,4.8-2.3,6.4c-1.5,1.5-3.5,2.3-6.1,2.3c-2.6,0-4.6-0.8-6.1-2.3s-2.2-3.7-2.2-6.4V70.9h4.2v12.9
c0,1.4,0.4,2.5,1.1,3.2c0.7,0.8,1.8,1.2,3.1,1.2c1.3,0,2.3-0.4,3-1.2C90.8,86.3,91.1,85.2,91.1,83.8z M97.9,70.9h4.5l6.1,16.1
l6.1-16.1h4.5l-7.8,20.7h-5.5L97.9,70.9z M122.2,91.5V70.9h12v3.6h-7.8v4.8h6.7v3.6h-6.7V88h7.8v3.6H122.2z M139.1,91.5V70.9h6.3
c2.3,0,4.1,0.6,5.4,1.7c1.3,1.1,2,2.6,2,4.5c0,1.2-0.3,2.3-0.9,3.2c-0.6,0.9-1.4,1.6-2.4,2.1l6.5,9.1h-5l-5.5-8.3h-2.2v8.3H139.1z
M145.7,74.4h-2.4v5.2h2.4c0.9,0,1.6-0.2,2.1-0.7c0.5-0.5,0.7-1.1,0.7-1.9c0-0.8-0.2-1.4-0.7-1.9C147.2,74.7,146.6,74.4,145.7,74.4
z M158.6,91.5V70.9h5.4l9.2,14.8V70.9h4.2v20.7H172l-9.2-14.8v14.8H158.6z M183.1,91.5V70.9h12v3.6h-7.8v4.8h6.7v3.6h-6.7V88h7.8
v3.6H183.1z M200,91.5V70.9h5.3l5,8.5l5-8.5h5.3v20.7h-4.2V76.8l-4.6,7.6h-3l-4.6-7.6v14.7H200z M226.3,91.5V70.9h12v3.6h-7.8v4.8
h6.7v3.6h-6.7V88h7.8v3.6H226.3z M243.2,91.5V70.9h5.4l9.2,14.8V70.9h4.2v20.7h-5.4l-9.2-14.8v14.8H243.2z M265.7,74.7v-3.8h16.9
v3.8h-6.4v16.8H272V74.7H265.7z"
/>
<g id="Marianne">
<path
id="Fond_2_"
class="st0"
d="M63.6,53.4c0.3-0.3,0.6-0.6,0.9-1h0c0.6-0.6,1.1-1.3,1.8-1.8c0.2-0.2,0.4-0.3,0.6-0.5
c0.1-0.1,0.1-0.2,0.1-0.2c-0.3,0.1-0.4,0.3-0.7,0.4c-0.1,0-0.1-0.1-0.1-0.1c0.2-0.1,0.4-0.3,0.6-0.4c0,0,0,0,0,0
c-0.1,0-0.1-0.1-0.1-0.1c-0.7-0.1-1.2,0.4-1.7,0.8c-0.1,0.1-0.2-0.1-0.3-0.1c-0.8,0.3-1.4,1-2.2,1.3v-0.1c-0.3,0.1-0.6,0.3-1,0.4
c-0.5,0.1-0.9,0.1-1.3,0.1c-0.6,0.1-1.3,0.2-1.9,0.3c0,0,0,0-0.1,0c-0.3,0.1-0.7,0.2-1,0.4c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1
c-0.1,0.1-0.2,0.2-0.4,0.3c-0.3,0.2-0.6,0.5-0.9,0.7c0,0-0.1,0-0.1,0c-0.3,0.3-0.6,0.6-0.9,0.8c0,0-0.1,0-0.2,0c0,0,0,0,0,0
c0,0,0,0,0-0.1c0-0.1,0.1-0.2,0.1-0.2c0.1-0.1,0.1-0.2,0.2-0.2c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0-0.1,0-0.1c0,0,0,0-0.1,0
c0.3-0.3,0.6-0.5,0.9-0.7v0c0,0-0.1,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-0.2,0.1-0.3,0.2
c-0.1,0.1-0.2,0.4-0.4,0.3c0,0-0.1,0-0.1,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0
c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1
c0.1-0.1,0.2-0.2,0.4-0.3h0c0.2-0.1,0.4-0.2,0.6-0.3c0,0,0.1-0.1,0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.4c0,0-0.1,0-0.1,0
c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1,0,0.1,0.1c1.7-1.3,4.1-1,6.1-1.7c0.2-0.1,0.3-0.2,0.5-0.3
c0.3-0.1,0.5-0.4,0.8-0.5c0.4-0.3,0.7-0.7,0.9-1.2c0-0.1-0.1-0.1-0.1-0.1c-0.7,0.8-1.5,1.3-2.4,1.8c-1.1,0.6-2.4,0.5-3.5,0.6
c0.1-0.1,0.2-0.1,0.3-0.1c0-0.2,0.1-0.2,0.2-0.3H59c0.1,0,0.1-0.1,0.1-0.1c0.1,0,0.3-0.1,0.2-0.1c-0.2-0.2-0.5,0.2-0.8,0
c0.1-0.1,0.1-0.3,0.2-0.3h0.2c0-0.1,0.1-0.2,0.1-0.2c0.8-0.5,1.6-0.9,2.3-1.3c-0.2,0-0.3,0.2-0.4,0.1c0.1,0,0-0.2,0.1-0.2
c0.6-0.2,1.1-0.5,1.7-0.7c-0.2,0-0.4,0.2-0.6,0c0.1-0.1,0.2-0.2,0.3-0.2v-0.2c0-0.1,0.1-0.1,0.1-0.1c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.1,0.2-0.1,0.3-0.2c-0.1,0-0.2,0-0.2-0.1c0.2-0.2,0.4-0.3,0.7-0.3c-0.1-0.1-0.2,0-0.2-0.1c0-0.1,0.1-0.1,0.1-0.1H63
c-0.1-0.1-0.1-0.2-0.1-0.2c0.3-0.4,0.3-0.9,0.5-1.3c-0.1,0-0.1,0-0.1-0.1c-0.5,0.6-1.4,0.8-2.2,1h-0.3c-0.3,0.1-0.6,0.1-0.9-0.1
c-0.2-0.1-0.3-0.3-0.5-0.4c-0.4-0.3-0.9-0.5-1.3-0.6c-1.3-0.4-2.7-0.6-4.1-0.6c0.6-0.3,1.2-0.3,1.9-0.5c0.9-0.3,1.8-0.6,2.7-0.5
c-0.2-0.1-0.4,0-0.5,0c-0.8-0.1-1.5,0.2-2.3,0.3c-0.5,0.1-1,0.3-1.6,0.4c-0.3,0.1-0.5,0.4-0.9,0.4v-0.2c0.5-0.6,1.2-1.3,2-1.3
c1-0.2,1.9,0,2.8,0.1c0.7,0.1,1.3,0.2,2,0.4c0.3,0,0.3,0.4,0.5,0.5c0.3,0.1,0.6,0,1,0.2c0-0.1-0.1-0.2,0-0.3
c0.2-0.2,0.5,0.1,0.7-0.1c0.4-0.3-0.4-0.7-0.6-1.1c0-0.1,0.1-0.1,0.1-0.1c0.4,0.4,0.7,0.8,1.3,1.1c0.3,0.1,0.9,0.3,0.8-0.1
c-0.3-0.6-0.8-1.1-1.2-1.6v-0.2c-0.1,0-0.1-0.1-0.2-0.1v-0.2c-0.2-0.1-0.2-0.3-0.3-0.5c-0.2-0.3-0.1-0.6-0.2-1
C62.1,39.3,62,39,62,38.7c-0.2-0.9-0.4-1.7-0.5-2.6c-0.1-1,0.6-1.8,1.1-2.7c0.4-0.6,0.8-1.3,1.5-1.7c0.2-0.6,0.6-1.2,1-1.7
c0.4-0.5,1.1-0.8,1.7-1.1c0.7-0.3,1.4-0.5,1.4-0.5l10.7,0c0,0,0.1,0,0.3,0.1c0.2,0.1,0.6,0.3,0.8,0.4c0.4,0.2,0.8,0.5,1,0.9
c0.1,0.2,0.3,0.5,0.2,0.7c-0.1,0.3-0.2,0.7-0.4,0.8c-0.3,0.2-0.7,0.2-1.1,0.1c-0.2,0-0.4-0.1-0.6-0.1c0.8,0.3,1.6,0.7,2.1,1.4
c0.1,0.1,0.3,0.2,0.5,0.2c0.1,0,0.1,0.1,0.1,0.2c-0.1,0.1-0.2,0.2-0.2,0.3h0.2c0.3-0.1,0.2-0.6,0.6-0.5c0.3,0.2,0.4,0.5,0.2,0.8
c-0.2,0.2-0.4,0.4-0.6,0.5c-0.1,0.1-0.1,0.3,0,0.4c0.2,0.2,0.2,0.4,0.3,0.6c0.2,0.4,0.2,0.8,0.4,1.2c0.2,0.8,0.4,1.6,0.4,2.4
c0,0.4-0.2,0.8-0.1,1.2c0.1,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.6,0.9c0.3,0.5,0.9,1.1,0.6,1.7c-0.2,0.4-0.8,0.3-1.1,0.5
c-0.3,0.3-0.1,0.7,0.1,1c0.3,0.5-0.3,0.8-0.7,1c0.1,0.2,0.3,0.1,0.4,0.2c0.1,0.3,0.3,0.4,0.2,0.7c-0.2,0.3-0.9,0.5-0.5,1
c0.2,0.4,0.1,0.8-0.1,1.2c-0.2,0.5-0.6,0.7-1,0.8c-0.3,0.1-0.7,0.1-1,0.1c-0.1-0.1-0.2-0.1-0.3-0.1c-0.9-0.1-1.8-0.4-2.7-0.4
c-0.3,0.1-0.5,0.1-0.7,0.2c-0.2,0.2-0.5,0.4-0.6,0.6c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0,0,0,0,0.1
c-0.2,0.2-0.3,0.4-0.4,0.6c0,0,0,0,0,0c0,0,0,0.1,0,0.1c-0.2,0.3-0.3,0.7-0.4,1c-0.4,1.2-0.2,2.3,0.1,2.5c0.1,0.1,1.8,0.6,2.9,1.1
c0.6,0.2,0.9,0.4,1.3,0.6l-22,0c1-0.7,2-1.1,3.5-1.8C61.6,54.6,63.1,53.9,63.6,53.4 M55.4,49.5c-0.1,0-0.3,0.1-0.3-0.1
c0.1-0.3,0.4-0.3,0.6-0.4c0.1-0.1,0.3-0.2,0.4-0.1c0.1,0.2,0.3,0.1,0.4,0.2C56.2,49.5,55.8,49.4,55.4,49.5 M47.2,48.3
c0,0-0.1-0.1-0.1-0.1c0.7-0.9,1.2-1.8,1.7-2.7c0.7-0.4,1.3-0.9,1.8-1.5c0.9-1,1.9-1.8,3-2.4c0.4-0.2,1-0.1,1.4,0.1
c-0.2,0.2-0.4,0.2-0.6,0.3c-0.1,0-0.1,0-0.2-0.1c0.1-0.1,0.1-0.1,0.1-0.2c-0.5,0.6-1.3,0.9-1.7,1.6c-0.3,0.5-0.5,1.2-1.2,1.4
c-0.2,0.1,0.1-0.2-0.1-0.1C49.6,45.7,48.5,46.9,47.2,48.3 M51.6,44.8c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.2-0.2,0.2
c-0.1,0-0.1,0-0.1-0.1c0.1-0.2,0.2-0.4,0.4-0.5C51.6,44.7,51.6,44.8,51.6,44.8 M54.1,52.8c0,0.1-0.1,0.1-0.1,0.2
c0.1,0,0.1,0,0.1,0.1c-0.1,0.1-0.2,0.2-0.4,0.3c0,0,0,0-0.1,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.4,0-0.3-0.1
c0.1-0.1,0.3-0.3,0.4-0.4c0.1-0.1,0.2-0.1,0.2-0.2c0,0,0.1-0.1,0.1-0.1C53.9,52.8,54.2,52.7,54.1,52.8 M53.2,52.4
C53.2,52.4,53.1,52.4,53.2,52.4c-0.2,0.2-0.4,0.3-0.6,0.4c-0.2,0.1-0.5,0.2-0.7,0.3c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-0.2,0.1-0.4,0.3-0.5,0.4c0,0,0,0-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0
c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0
c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1
l0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0-0.1l0,0c0,0,0,0,0,0c0.1-0.1,0.1-0.1,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0.1-0.1
c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0-0.1
c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0,0,0,0c-0.1,0-0.1,0.1-0.2,0.2c-0.1,0-0.2,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0
c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.5-0.5,1.4-0.5,2.1-0.8
c0.3-0.1,0.6,0.1,0.9,0c0.2,0,0.3,0,0.5,0.1C54,51.8,53.6,52.1,53.2,52.4 M54.3,48.7c-0.1-0.1,0.2,0,0.2-0.1H54
c-0.1,0-0.1-0.1-0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.2c-0.4,0.1-0.7,0.4-1.1,0.5c-0.6,0.2-1.1,0.7-1.7,0.9c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.2,0.3-0.2,0.4-0.4c0-0.1,0-0.1-0.1-0.1c0.4-0.6,1-0.9,1.6-1.4v-0.2c0.2-0.2,0.4-0.3,0.5-0.6c0.1-0.2,0.3-0.4,0.5-0.5
c-0.1-0.1-0.2-0.1-0.2-0.2c-0.2,0-0.4,0.1-0.6-0.1c0.1-0.1,0.2-0.2,0.3-0.2c0,0-0.1,0-0.1-0.1c-0.1-0.1,0.1-0.2,0.3-0.3
c0.2-0.1,0.5-0.1,0.6-0.2c-0.4-0.1-0.8,0.1-1.2-0.1c0.3-0.7,0.7-1.3,1.3-1.6c0.1,0,0.2,0,0.2,0.1c0,0.3-0.2,0.5-0.4,0.5
c0.4,0.1,0.9,0.1,1.3,0.3c-0.1,0.1-0.2,0.1-0.2,0.1c0.3,0.2,0.6,0.1,0.9,0.3c-0.2,0.2-0.3,0-0.5,0c1.7,0.5,3.4,0.9,4.8,1.9
c-1.2,0.6-2.4,0.9-3.7,1.1c-0.2,0-0.3,0-0.4-0.1c0,0.1,0,0.2-0.1,0.2c-0.2,0-0.4,0-0.5,0.1C54.7,48.8,54.4,48.8,54.3,48.7"
/>
<path
id="Rouge_1_"
class="st1"
d="M63.6,53.4c0.3-0.3,0.6-0.6,0.9-1h0c0.6-0.6,1.1-1.3,1.8-1.8c0.2-0.2,0.4-0.3,0.6-0.5
c0.1-0.1,0.1-0.2,0.1-0.2c-0.3,0.1-0.4,0.3-0.7,0.4c-0.1,0-0.1-0.1-0.1-0.1c0.2-0.1,0.4-0.3,0.6-0.4c0,0,0,0,0,0
c-0.1,0-0.1-0.1-0.1-0.1c-0.7-0.1-1.2,0.4-1.7,0.8c-0.1,0.1-0.2-0.1-0.3-0.1c-0.8,0.3-1.4,1-2.2,1.3v-0.1c-0.3,0.1-0.6,0.3-1,0.4
c-0.5,0.1-0.9,0.1-1.3,0.1c-0.6,0.1-1.3,0.2-1.9,0.3c0,0,0,0-0.1,0c-0.3,0.1-0.7,0.2-1,0.4c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1
c-0.1,0.1-0.2,0.2-0.4,0.3c-0.3,0.2-0.6,0.5-0.9,0.7c0,0-0.1,0-0.1,0c-0.3,0.3-0.6,0.6-0.9,0.8c0,0-0.1,0-0.2,0c0,0,0,0,0,0
c0,0,0,0,0-0.1c0-0.1,0.1-0.2,0.1-0.2c0.1-0.1,0.1-0.2,0.2-0.2c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0-0.1,0-0.1c0,0,0,0-0.1,0
c0.3-0.3,0.6-0.5,0.9-0.7v0c0,0-0.1,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-0.2,0.1-0.3,0.2
c-0.1,0.1-0.2,0.4-0.4,0.3c0,0-0.1,0-0.1,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0
c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1
c0.1-0.1,0.2-0.2,0.4-0.3h0c0.2-0.1,0.4-0.2,0.6-0.3c0,0,0.1-0.1,0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.4c0,0-0.1,0-0.1,0
c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1,0,0.1,0.1c1.7-1.3,4.1-1,6.1-1.7c0.2-0.1,0.3-0.2,0.5-0.3
c0.3-0.1,0.5-0.4,0.8-0.5c0.4-0.3,0.7-0.7,0.9-1.2c0-0.1-0.1-0.1-0.1-0.1c-0.7,0.8-1.5,1.3-2.4,1.8c-1.1,0.6-2.4,0.5-3.5,0.6
c0.1-0.1,0.2-0.1,0.3-0.1c0-0.2,0.1-0.2,0.2-0.3H59c0.1,0,0.1-0.1,0.1-0.1c0.1,0,0.3-0.1,0.2-0.1c-0.2-0.2-0.5,0.2-0.8,0
c0.1-0.1,0.1-0.3,0.2-0.3h0.2c0-0.1,0.1-0.2,0.1-0.2c0.8-0.5,1.6-0.9,2.3-1.3c-0.2,0-0.3,0.2-0.4,0.1c0.1,0,0-0.2,0.1-0.2
c0.6-0.2,1.1-0.5,1.7-0.7c-0.2,0-0.4,0.2-0.6,0c0.1-0.1,0.2-0.2,0.3-0.2v-0.2c0-0.1,0.1-0.1,0.1-0.1c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.1,0.2-0.1,0.3-0.2c-0.1,0-0.2,0-0.2-0.1c0.2-0.2,0.4-0.3,0.7-0.3c-0.1-0.1-0.2,0-0.2-0.1c0-0.1,0.1-0.1,0.1-0.1H63
c-0.1-0.1-0.1-0.2-0.1-0.2c0.3-0.4,0.3-0.9,0.5-1.3c-0.1,0-0.1,0-0.1-0.1c-0.5,0.6-1.4,0.8-2.2,1h-0.3c-0.3,0.1-0.6,0.1-0.9-0.1
c-0.2-0.1-0.3-0.3-0.5-0.4c-0.4-0.3-0.9-0.5-1.3-0.6c-1.3-0.4-2.7-0.6-4.1-0.6c0.6-0.3,1.2-0.3,1.9-0.5c0.9-0.3,1.8-0.6,2.7-0.5
c-0.2-0.1-0.4,0-0.5,0c-0.8-0.1-1.5,0.2-2.3,0.3c-0.5,0.1-1,0.3-1.6,0.4c-0.3,0.1-0.5,0.4-0.9,0.4V44c0.5-0.6,1.2-1.3,2-1.3
c1-0.2,1.9,0,2.8,0.1c0.7,0.1,1.3,0.2,2,0.4c0.3,0,0.3,0.4,0.5,0.5c0.3,0.1,0.6,0,1,0.2c0-0.1-0.1-0.2,0-0.3
c0.2-0.2,0.5,0.1,0.7-0.1c0.4-0.3-0.4-0.7-0.6-1.1c0-0.1,0.1-0.1,0.1-0.1c0.4,0.4,0.7,0.8,1.3,1.1c0.3,0.1,0.9,0.3,0.8-0.1
c-0.3-0.6-0.8-1.1-1.2-1.6v-0.2c-0.1,0-0.1-0.1-0.2-0.1v-0.2c-0.2-0.1-0.2-0.3-0.3-0.5c-0.2-0.3-0.1-0.6-0.2-1
C62.1,39.3,62,39,62,38.7c-0.2-0.9-0.4-1.7-0.5-2.6c-0.1-1,0.6-1.8,1.1-2.7c0.4-0.6,0.8-1.3,1.5-1.7c0.2-0.6,0.6-1.2,1-1.7
c0.4-0.5,1.1-0.8,1.7-1.1c0.7-0.3,1.4-0.5,1.4-0.5H31.4v28.3h26c1-0.7,2-1.1,3.5-1.8C61.6,54.6,63.1,53.9,63.6,53.4 M55.4,49.5
c-0.1,0-0.3,0.1-0.3-0.1c0.1-0.3,0.4-0.3,0.6-0.4c0.1-0.1,0.3-0.2,0.4-0.1c0.1,0.2,0.3,0.1,0.4,0.2C56.2,49.5,55.8,49.4,55.4,49.5
M47.2,48.3c0,0-0.1-0.1-0.1-0.1c0.7-0.9,1.2-1.8,1.7-2.7c0.7-0.4,1.3-0.9,1.8-1.5c0.9-1,1.9-1.8,3-2.4c0.4-0.2,1-0.1,1.4,0.1
c-0.2,0.2-0.4,0.2-0.6,0.3c-0.1,0-0.1,0-0.2-0.1c0.1-0.1,0.1-0.1,0.1-0.2c-0.5,0.6-1.3,0.9-1.7,1.6c-0.3,0.5-0.5,1.2-1.2,1.4
c-0.2,0.1,0.1-0.2-0.1-0.1C49.7,45.7,48.5,46.9,47.2,48.3 M51.6,44.8c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.2-0.2,0.2
c-0.1,0-0.1,0-0.1-0.1c0.1-0.2,0.2-0.4,0.4-0.5C51.6,44.7,51.6,44.8,51.6,44.8 M54.1,52.8c0,0.1-0.1,0.1-0.1,0.2
c0.1,0,0.1,0,0.1,0.1c-0.1,0.1-0.2,0.2-0.4,0.3c0,0,0,0-0.1,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.4,0-0.3-0.1
c0.1-0.1,0.3-0.3,0.4-0.4c0.1-0.1,0.2-0.1,0.2-0.2c0,0,0.1-0.1,0.1-0.1C53.9,52.8,54.2,52.7,54.1,52.8 M53.2,52.4
C53.2,52.4,53.2,52.4,53.2,52.4c-0.2,0.2-0.4,0.3-0.6,0.4c-0.2,0.1-0.5,0.2-0.7,0.3c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-0.2,0.1-0.4,0.3-0.5,0.4c0,0,0,0-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0
c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0
c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1
l0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0-0.1l0,0c0,0,0,0,0,0c0.1-0.1,0.1-0.1,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0.1-0.1
c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0-0.1
c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0,0,0,0c-0.1,0-0.1,0.1-0.2,0.2c-0.1,0-0.2,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0
c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.5-0.5,1.4-0.5,2.1-0.8
c0.3-0.1,0.6,0.1,0.9,0c0.2,0,0.3,0,0.5,0.1C54,51.8,53.6,52.1,53.2,52.4 M54.3,48.7c-0.1-0.1,0.2,0,0.2-0.1H54
c-0.1,0-0.1-0.1-0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.2c-0.4,0.1-0.7,0.4-1.1,0.5c-0.6,0.2-1.1,0.7-1.7,0.9c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.2,0.3-0.2,0.4-0.4c0-0.1,0-0.1-0.1-0.1c0.4-0.6,1-0.9,1.6-1.4v-0.2c0.2-0.2,0.4-0.3,0.5-0.6c0.1-0.2,0.3-0.4,0.5-0.5
c-0.1-0.1-0.2-0.1-0.2-0.2c-0.2,0-0.4,0.1-0.6-0.1c0.1-0.1,0.2-0.2,0.3-0.2c0,0-0.1,0-0.1-0.1c-0.1-0.1,0.1-0.2,0.3-0.3
c0.2-0.1,0.5-0.1,0.6-0.2c-0.4-0.1-0.8,0.1-1.2-0.1c0.3-0.7,0.7-1.3,1.3-1.6c0.1,0,0.2,0,0.2,0.1c0,0.3-0.2,0.5-0.4,0.5
c0.4,0.1,0.9,0.1,1.3,0.3c-0.1,0.1-0.2,0.1-0.2,0.1c0.3,0.2,0.6,0.1,0.9,0.3c-0.2,0.2-0.3,0-0.5,0c1.7,0.5,3.4,0.9,4.8,1.9
c-1.2,0.6-2.4,0.9-3.7,1.1c-0.2,0-0.3,0-0.4-0.1c0,0.1,0,0.2-0.1,0.2c-0.2,0-0.4,0-0.5,0.1C54.7,48.8,54.4,48.8,54.3,48.7"
/>
<path
id="Bleu_1_"
class="st2"
d="M109.3,28.4H78.9c0,0,0.1,0,0.3,0.1c0.2,0.1,0.6,0.3,0.8,0.4c0.4,0.2,0.8,0.5,1,0.9
c0.1,0.2,0.3,0.5,0.2,0.7c-0.1,0.3-0.2,0.7-0.4,0.8c-0.3,0.2-0.7,0.2-1.1,0.1c-0.2,0-0.4-0.1-0.6-0.1c0.8,0.3,1.6,0.7,2.1,1.4
c0.1,0.1,0.3,0.2,0.5,0.2c0.1,0,0.1,0.1,0.1,0.2c-0.1,0.1-0.2,0.2-0.2,0.3h0.2c0.3-0.1,0.2-0.6,0.6-0.5c0.3,0.2,0.4,0.5,0.2,0.8
c-0.2,0.2-0.4,0.4-0.6,0.5c-0.1,0.1-0.1,0.3,0,0.4c0.2,0.2,0.2,0.4,0.3,0.6c0.2,0.4,0.2,0.8,0.4,1.2c0.2,0.8,0.4,1.6,0.4,2.4
c0,0.4-0.2,0.8-0.1,1.2c0.1,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.6,0.9c0.3,0.5,0.9,1.1,0.6,1.7c-0.2,0.4-0.8,0.3-1.1,0.5
c-0.3,0.3-0.1,0.7,0.1,1c0.3,0.5-0.3,0.8-0.7,1c0.1,0.2,0.3,0.1,0.4,0.2c0.1,0.3,0.3,0.4,0.2,0.7c-0.2,0.3-0.9,0.5-0.5,1
c0.2,0.4,0.1,0.8-0.1,1.2c-0.2,0.5-0.6,0.7-1,0.8c-0.3,0.1-0.7,0.1-1,0.1c-0.1-0.1-0.2-0.1-0.3-0.1c-0.9-0.1-1.8-0.4-2.7-0.4
c-0.3,0.1-0.5,0.1-0.7,0.2c-0.2,0.2-0.5,0.4-0.6,0.6c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0,0,0,0,0.1
c-0.2,0.2-0.3,0.4-0.4,0.6c0,0,0,0,0,0c0,0,0,0.1,0,0.1c-0.2,0.3-0.3,0.7-0.4,1c-0.4,1.2-0.2,2.3,0.1,2.5c0.1,0.1,1.8,0.6,2.9,1.1
c0.6,0.2,0.9,0.4,1.3,0.6h29.9V28.4z"
/>
<path
id="Yeux_1_"
class="st3"
d="M80.7,38.7c0.2,0.1,0.5,0.1,0.5,0.2c-0.1,0.4-0.7,0.5-1.1,1H80c-0.2,0.1-0.1,0.4-0.3,0.4
c-0.2-0.1-0.3,0-0.5,0.1c0.2,0.2,0.5,0.4,0.8,0.3c0.1,0,0.2,0.1,0.2,0.2c0,0,0.1,0,0.1-0.1c0.1,0,0.1,0,0.1,0.1V41
c-0.2,0.2-0.4,0.1-0.6,0.2c0.4,0.1,0.9,0.1,1.2,0c0.3-0.1,0-0.6,0.2-0.9c-0.1,0,0-0.2-0.1-0.2c0.1-0.1,0.2-0.3,0.3-0.3
c0.1,0,0.3-0.1,0.3-0.2c0-0.1-0.2-0.2-0.2-0.3c0.3-0.2,0.6-0.5,0.5-0.9c-0.1-0.2-0.5-0.2-0.8-0.3c-0.3-0.1-0.6,0-0.9,0.1
c-0.3,0-0.5,0.2-0.8,0.2c-0.4,0.1-0.7,0.3-1,0.5c0.4-0.2,0.8-0.2,1.2-0.3C80.1,38.7,80.3,38.6,80.7,38.7"
/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -9,6 +9,7 @@ import { useEffect } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
import { useRouteChangeCompleteFocus } from '@/hooks/useRouteChangeCompleteFocus';
import { useResponsiveStore } from '@/stores/';
import { ConfigProvider } from './config/';
@@ -51,6 +52,7 @@ const queryClient = new QueryClient({
export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const { replace } = useRouter();
useRouteChangeCompleteFocus();
const initializeResizeListener = useResponsiveStore(
(state) => state.initializeResizeListener,

View File

@@ -27,6 +27,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { AnalyticsProvider } = useAnalytics();
const { i18n } = useTranslation();
const languageSynchronized = useRef(false);
const favicon = conf?.theme_customization?.favicon;
useEffect(() => {
if (!user || languageSynchronized.current) {
@@ -99,6 +100,25 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
{conf?.FRONTEND_JS_URL && (
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
)}
{favicon?.light.href && (
<Head>
<link
rel="icon"
media="(prefers-color-scheme: light)"
{...favicon.light}
/>
</Head>
)}
{favicon?.dark.href && (
<Head>
<link
rel="icon"
media="(prefers-color-scheme: dark)"
{...favicon.dark}
/>
</Head>
)}
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AnalyticsProvider>{children}</AnalyticsProvider>
</>
);

View File

@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { Resource } from 'i18next';
import Image from 'next/image';
import { LinkHTMLAttributes } from 'react';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
@@ -7,8 +9,18 @@ import { FooterType } from '@/features/footer';
import { HeaderType, WaffleType } from '@/features/header';
import { PostHogConf } from '@/services';
type Imagetype = React.ComponentProps<typeof Image>;
interface ThemeCustomization {
favicon?: {
light: LinkHTMLAttributes<HTMLLinkElement>;
dark: LinkHTMLAttributes<HTMLLinkElement>;
};
footer?: FooterType;
home: {
'with-proconnect'?: boolean;
'icon-banner'?: Imagetype;
};
translations?: Resource;
header?: HeaderType;
waffle?: WaffleType;
@@ -16,6 +28,7 @@ interface ThemeCustomization {
export interface ConfigResponse {
AI_FEATURE_ENABLED?: boolean;
API_USERS_SEARCH_QUERY_MIN_LENGTH?: number;
COLLABORATION_WS_URL?: string;
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean;
CONVERSION_FILE_EXTENSIONS_ALLOWED: string[];

View File

@@ -1,16 +1,16 @@
import { useCunninghamTheme } from '../useCunninghamTheme';
describe('<useCunninghamTheme />', () => {
it('has the logo correctly set', () => {
expect(useCunninghamTheme.getState().componentTokens.logo?.src).toBe('');
it('changing theme update tokens', () => {
expect(
useCunninghamTheme.getState().currentTokens.globals?.font.families.base,
).toBe('Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif');
// Change theme
useCunninghamTheme.getState().setTheme('dsfr');
const { componentTokens } = useCunninghamTheme.getState();
const logo = componentTokens.logo;
expect(logo?.src).toBe('/assets/logo-gouv.svg');
expect(logo?.widthHeader).toBe('110px');
expect(logo?.widthFooter).toBe('220px');
expect(
useCunninghamTheme.getState().currentTokens.globals?.font.families.base,
).toBe('Marianne, Inter, Roboto Flex Variable, sans-serif');
});
});

View File

@@ -889,16 +889,6 @@
--c--components--badge--info--color: var(
--c--contextuals--content--semantic--info--secondary
);
--c--components--logo--src: ;
--c--components--logo--alt: ;
--c--components--logo--widthheader: ;
--c--components--logo--widthfooter: ;
--c--components--home-proconnect: false;
--c--components--icon--src: /assets/icon-docs.svg;
--c--components--icon--width: 32px;
--c--components--icon--height: auto;
--c--components--favicon--png-light: /assets/favicon-light.png;
--c--components--favicon--png-dark: /assets/favicon-dark.png;
}
.cunningham-theme--dark {
@@ -2589,17 +2579,6 @@
--c--components--badge--info--color: var(
--c--contextuals--content--semantic--info--secondary
);
--c--components--logo--src: /assets/logo-gouv.svg;
--c--components--logo--alt: gouvernement logo;
--c--components--logo--widthHeader: 110px;
--c--components--logo--widthFooter: 220px;
--c--components--home-proconnect: true;
--c--components--icon--src: /assets/icon-docs-dsfr.svg;
--c--components--icon--width: 32px;
--c--components--icon--height: auto;
--c--components--favicon--png-light: /assets/favicon-dsfr.png;
--c--components--favicon--png-dark: /assets/favicon-dark-dsfr.png;
--c--components--favicon--ico: /assets/favicon-dsfr.ico;
}
.clr-logo-1-light {

View File

@@ -676,13 +676,6 @@ export const tokens = {
warning: { 'background-color': '#F1E0D3', color: '#AD3300' },
info: { 'background-color': '#D5E4F3', color: '#005BC0' },
},
logo: { src: '', alt: '', widthHeader: '', widthFooter: '' },
'home-proconnect': false,
icon: { src: '/assets/icon-docs.svg', width: '32px', height: 'auto' },
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
},
},
dark: {
@@ -1966,23 +1959,6 @@ export const tokens = {
warning: { 'background-color': '#F1E0D3', color: '#AD3300' },
info: { 'background-color': '#D5E4F3', color: '#005BC0' },
},
logo: {
src: '/assets/logo-gouv.svg',
alt: 'Gouvernement Logo',
widthHeader: '110px',
widthFooter: '220px',
},
'home-proconnect': true,
icon: {
src: '/assets/icon-docs-dsfr.svg',
width: '32px',
height: 'auto',
},
favicon: {
'png-light': '/assets/favicon-dsfr.png',
'png-dark': '/assets/favicon-dark-dsfr.png',
ico: '/assets/favicon-dsfr.ico',
},
},
},
},

View File

@@ -0,0 +1,68 @@
import { render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import React from 'react';
import { describe, expect, test, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { UserReconciliation } from '../components/UserReconciliation';
vi.mock('../assets/mail-check-filled.svg', () => ({
default: () => <div data-testid="success-svg">SuccessSvg</div>,
}));
describe('UserReconciliation', () => {
beforeEach(() => {
fetchMock.reset();
});
['active', 'inactive'].forEach((type) => {
test(`renders when reconciliation is a ${type} success`, async () => {
fetchMock.get(
`http://test.jest/api/v1.0/user-reconciliations/${type}/123456/`,
{ details: 'Success' },
);
render(
<UserReconciliation
type={type as 'active' | 'inactive'}
reconciliationId="123456"
/>,
{
wrapper: AppWrapper,
},
);
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(
await screen.findByText(
/To complete the unification of your user accounts/i,
),
).toBeInTheDocument();
});
});
test('renders when reconciliation fails', async () => {
fetchMock.get(
`http://test.jest/api/v1.0/user-reconciliations/active/invalid-id/`,
{
throws: new Error('invalid id'),
},
);
render(<UserReconciliation type="active" reconciliationId="invalid-id" />, {
wrapper: AppWrapper,
});
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(
await screen.findByText(/An error occurred during email validation./i),
).toBeInTheDocument();
});
});

View File

@@ -1,2 +1,3 @@
export * from './useAuthQuery';
export * from './types';
export * from './useAuthQuery';
export * from './useUserReconciliations';

View File

@@ -0,0 +1,51 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
type UserReconciliationResponse = {
details: string;
};
interface UserReconciliationProps {
type: 'active' | 'inactive';
reconciliationId?: string;
}
export const userReconciliations = async ({
type,
reconciliationId,
}: UserReconciliationProps): Promise<UserReconciliationResponse> => {
const response = await fetchAPI(
`user-reconciliations/${type}/${reconciliationId}/`,
);
if (!response.ok) {
throw new APIError(
'Failed to do the user reconciliation',
await errorCauses(response),
);
}
return response.json() as Promise<UserReconciliationResponse>;
};
export const KEY_USER_RECONCILIATIONS = 'user_reconciliations';
export function useUserReconciliationsQuery(
param: UserReconciliationProps,
queryConfig?: UseQueryOptions<
UserReconciliationResponse,
APIError,
UserReconciliationResponse
>,
) {
return useQuery<
UserReconciliationResponse,
APIError,
UserReconciliationResponse
>({
queryKey: [KEY_USER_RECONCILIATIONS, param],
queryFn: () => userReconciliations(param),
...queryConfig,
});
}

View File

@@ -0,0 +1,14 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12770_18024)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5924 17.5492C27.3201 17.5492 28.0035 17.686 28.6419 17.9606C29.2802 18.2352 29.8434 18.6165 30.3307 19.1038C30.8179 19.5911 31.1994 20.1579 31.474 20.8031C31.7551 21.4411 31.8958 22.1241 31.8958 22.8512C31.8958 23.5717 31.7552 24.2521 31.474 24.8903C31.1994 25.5354 30.8144 26.1022 30.3203 26.5895C29.833 27.0768 29.2663 27.4582 28.6211 27.7327C27.9828 28.0141 27.3063 28.1546 26.5924 28.1546C25.8649 28.1546 25.1813 28.0177 24.543 27.7432C23.9049 27.4687 23.3427 27.0835 22.8555 26.5895C22.3682 26.1022 21.9833 25.539 21.7018 24.9007C21.4273 24.2624 21.2904 23.5787 21.2904 22.8512C21.2904 22.124 21.4274 21.4412 21.7018 20.8031C21.9832 20.1647 22.3682 19.6016 22.8555 19.1143C23.3427 18.6202 23.9049 18.2351 24.543 17.9606C25.1813 17.6861 25.8649 17.5492 26.5924 17.5492ZM28.9609 20.1846C28.7003 20.1846 28.4947 20.2947 28.3438 20.514L25.9128 23.8708L24.7188 22.5635C24.6502 22.4949 24.5709 22.4406 24.4818 22.3994C24.3926 22.3582 24.2863 22.3369 24.1628 22.3369C23.9637 22.3369 23.7913 22.4054 23.6471 22.5426C23.5032 22.673 23.431 22.8492 23.431 23.0687C23.4311 23.1576 23.4489 23.2467 23.4831 23.3356C23.5242 23.4316 23.5763 23.5215 23.638 23.6038L25.3672 25.4775C25.4358 25.5598 25.5257 25.6213 25.6354 25.6624C25.7451 25.7036 25.8515 25.7249 25.9544 25.7249C26.2219 25.7249 26.4239 25.6314 26.5612 25.4463L29.5378 21.3486C29.5926 21.2732 29.6304 21.1975 29.651 21.1221C29.6784 21.0468 29.6913 20.978 29.6914 20.9163C29.6914 20.7105 29.6158 20.538 29.4648 20.4007C29.3208 20.2569 29.1529 20.1846 28.9609 20.1846Z" fill="#367664"/>
<path d="M20.293 19.5153C20.0526 19.9618 19.8626 20.4428 19.7253 20.958C19.5948 21.4663 19.5299 21.9958 19.5299 22.5452C19.53 22.9022 19.5606 23.2528 19.6224 23.596C19.6842 23.9393 19.7704 24.2756 19.8802 24.6051H4.1888C3.81102 24.6051 3.47375 24.5639 3.17839 24.4814C2.88337 24.4059 2.63299 24.2996 2.42708 24.1624L10.2878 16.29L11.6992 17.5479C12.0221 17.8295 12.3451 18.0394 12.668 18.1768C12.9906 18.314 13.3271 18.3824 13.6771 18.3825C14.0273 18.3825 14.3647 18.3141 14.6875 18.1768C15.0172 18.0394 15.3438 17.8295 15.6667 17.5479L17.0781 16.29L20.293 19.5153Z" fill="#367664"/>
<path d="M8.97917 15.1468L1.32422 22.7822C1.24179 22.583 1.17699 22.3589 1.12891 22.1117C1.08772 21.8644 1.06641 21.5789 1.06641 21.2562V9.86165C1.06641 9.51134 1.08994 9.20825 1.13802 8.9541C1.19294 8.69333 1.25202 8.49729 1.3138 8.36686L8.97917 15.1468Z" fill="#367664"/>
<path d="M26.0521 8.36686C26.107 8.49725 26.1612 8.69345 26.2161 8.9541C26.2711 9.20825 26.2995 9.51134 26.2995 9.86165V15.7757C25.3104 15.7757 24.3789 15.9821 23.5065 16.3942C22.6413 16.7994 21.8993 17.3455 21.2812 18.0322L18.3867 15.1468L26.0521 8.36686Z" fill="#367664"/>
<path d="M22.9089 6.5127C23.7331 6.5127 24.4135 6.68136 24.9492 7.0179L14.625 16.1468C14.3161 16.4281 13.9997 16.5687 13.6771 16.5687C13.3613 16.5685 13.0449 16.4283 12.7292 16.1468L2.40625 7.0179C2.94872 6.68149 3.62829 6.51278 4.44531 6.5127H22.9089Z" fill="#367664"/>
</g>
<defs>
<clipPath id="clip0_12770_18024">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,97 @@
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import error_img from '@/assets/icons/error-coffee.png';
import { Box, Loading, Text } from '@/components';
import { useUserReconciliationsQuery } from '../api';
import SuccessSvg from '../assets/mail-check-filled.svg';
interface UserReconciliationProps {
reconciliationId: string;
type: 'active' | 'inactive';
}
export const UserReconciliation = ({
reconciliationId,
type,
}: UserReconciliationProps) => {
const { t } = useTranslation();
const { data: userReconciliations, isError } = useUserReconciliationsQuery({
type,
reconciliationId,
});
if (!userReconciliations && !isError) {
return (
<Loading
$height="100vh"
$width="100vw"
$position="absolute"
$css="top: 0;"
/>
);
}
let render = (
<Box $gap="xs" $align="center">
<SuccessSvg />
<Text
as="h3"
$textAlign="center"
$maxWidth="350px"
$theme="neutral"
$margin="0"
$size="16px"
>
{t('Email Address Confirmed')}
</Text>
<Text
as="p"
$textAlign="center"
$maxWidth="330px"
$theme="neutral"
$variation="secondary"
$margin="0"
$size="sm"
>
{t(
'To complete the unification of your user accounts, please click the confirmation links sent to all the email addresses you provided.',
)}
</Text>
</Box>
);
if (isError) {
render = (
<Box $gap="xs" $align="center">
<Image
src={error_img}
alt=""
width={300}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Text
as="p"
$textAlign="center"
$maxWidth="330px"
$theme="neutral"
$variation="secondary"
$margin="0"
$size="sm"
>
{t('An error occurred during email validation.')}
</Text>
</Box>
);
}
return (
<Box $align="center" $margin="auto" $padding={{ bottom: '2rem' }}>
{render}
</Box>
);
};

View File

@@ -1,3 +1,4 @@
export * from './Auth';
export * from './ButtonLogin';
export * from './UserAvatar';
export * from './UserReconciliation';

View File

@@ -1,5 +1,8 @@
import { APIError, errorCauses } from '@/api';
import { sleep } from '@/utils';
import { isSafeUrl } from '@/utils/url';
import { ANALYZE_URL } from '../conf';
interface CheckDocMediaStatusResponse {
file?: string;
@@ -13,6 +16,10 @@ interface CheckDocMediaStatus {
export const checkDocMediaStatus = async ({
urlMedia,
}: CheckDocMediaStatus): Promise<CheckDocMediaStatusResponse> => {
if (!isSafeUrl(urlMedia) || !urlMedia.includes(ANALYZE_URL)) {
throw new APIError('Url invalid', { status: 400 });
}
const response = await fetch(urlMedia, {
credentials: 'include',
});

View File

@@ -4,7 +4,7 @@
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#3b82f6"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 603 B

View File

@@ -15,14 +15,19 @@ import {
import { TFunction } from 'i18next';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { createGlobalStyle } from 'styled-components';
import { Box, Icon, Loading } from '@/components';
import { isSafeUrl } from '@/utils/url';
import Warning from '../../assets/warning.svg';
import { ANALYZE_URL } from '../../conf';
import { DocsBlockNoteEditor } from '../../types';
const PDFBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper {
width: fit-content;
}
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
width: 100% !important;
}
@@ -59,11 +64,7 @@ interface PdfBlockComponentProps {
>;
}
const PdfBlockComponent = ({
editor,
block,
contentRef,
}: PdfBlockComponentProps) => {
const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
const pdfUrl = block.props.url;
const { i18n, t } = useTranslation();
const lang = i18n.resolvedLanguage;
@@ -87,7 +88,7 @@ const PdfBlockComponent = ({
}, [lang, t]);
useEffect(() => {
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL) || !isSafeUrl(pdfUrl)) {
return;
}
@@ -114,27 +115,30 @@ const PdfBlockComponent = ({
void validatePDFContent();
}, [pdfUrl]);
const isInvalidPDF =
(!isPDFContentLoading && isPDFContent !== null && !isPDFContent) ||
!isSafeUrl(pdfUrl);
if (isInvalidPDF) {
return (
<Box
$direction="row"
$gap="0.5rem"
$width="inherit"
$css="pointer-events: none;"
contentEditable={false}
draggable={false}
>
<Warning />
{t('Invalid or missing PDF file.')}
</Box>
);
}
return (
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<>
<PDFBlockStyle />
{isPDFContentLoading && <Loading />}
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$height="300px"
$css={css`
text-align: center;
`}
contentEditable={false}
onClick={() => editor.setTextCursorPosition(block)}
>
{t('Invalid or missing PDF file.')}
</Box>
)}
<ResizableFileBlockWrapper
buttonIcon={
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
@@ -144,21 +148,19 @@ const PdfBlockComponent = ({
>
{!isPDFContentLoading && isPDFContent && (
<Box
as="embed"
as="iframe"
className="bn-visual-media"
role="presentation"
$width="100%"
$height="450px"
type="application/pdf"
src={pdfUrl}
aria-label={block.props.name || t('PDF document')}
contentEditable={false}
draggable={false}
onClick={() => editor.setTextCursorPosition(block)}
/>
)}
</ResizableFileBlockWrapper>
</Box>
</>
);
};

View File

@@ -9,8 +9,9 @@ import { createReactBlockSpec } from '@blocknote/react';
import { t } from 'i18next';
import { useEffect } from 'react';
import { Box, Text } from '@/components';
import { Box, Icon, Text } from '@/components';
import { useMediaUrl } from '@/core';
import { isSafeUrl } from '@/utils/url';
import { loopCheckDocMediaStatus } from '../../api';
import Loader from '../../assets/loader.svg';
@@ -67,7 +68,7 @@ const UploadLoaderBlockComponent = ({
block.props.type === 'loading' &&
isEditable;
if (!shouldCheckStatus) {
if (!shouldCheckStatus || !isSafeUrl(block.props.blockUploadUrl)) {
return;
}
@@ -125,7 +126,11 @@ const UploadLoaderBlockComponent = ({
{block.props.type === 'warning' ? (
<Warning />
) : (
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
<Icon
$theme="brand"
$layer="border"
icon={<Loader style={{ animation: 'spin 1.5s linear infinite' }} />}
/>
)}
<Text>{block.props.information}</Text>
</Box>

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { backendUrl } from '@/api';
import { isSafeUrl } from '@/utils/url';
import { useCreateDocAttachment } from '../api';
import { ANALYZE_URL } from '../conf';
@@ -57,7 +58,8 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
if (
!block ||
!('url' in block.props) ||
('url' in block.props && !block.props.url.includes(ANALYZE_URL))
('url' in block.props && !block.props.url.includes(ANALYZE_URL)) ||
!isSafeUrl(block.props.url)
) {
return;
}

View File

@@ -7,17 +7,16 @@ export const cssEditor = css`
height: 100%;
}
& .bn-editor {
color: var(--c--globals--colors--gray-700);
}
/**
* WCAG Accessibility contrast fixes for BlockNote editor
* Token Mantime
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
color: #767676 !important;
font-weight: 400;
& > .bn-container {
--bn-colors-editor-text: var(
--c--contextuals--content--semantic--neutral--primary
);
--bn-colors-side-menu: var(
--c--contextuals--content--semantic--neutral--tertiary
);
}
/**
@@ -97,7 +96,9 @@ export const cssEditor = css`
height: 38px;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
color: var(
--c--contextuals--content--semantic--neutral--tertiary
) !important;
}
/**
@@ -158,6 +159,41 @@ export const cssEditor = css`
border: 1px solid #d3d2cf;
}
/**
* Checklist items
*/
.bn-block-content[data-content-type='checkListItem'] > div > input {
appearance: none;
width: 20px;
height: 20px;
border: 2px solid
var(--c--contextuals--content--semantic--neutral--tertiary);
border-radius: 4px;
cursor: pointer;
position: relative;
align-self: center;
margin-top: 2px;
}
.bn-block-content[data-content-type='checkListItem'] > div > input:checked {
background-color: var(--c--contextuals--content--semantic--brand--tertiary);
border-color: var(--c--contextuals--content--semantic--brand--tertiary);
}
.bn-block-content[data-content-type='checkListItem']
> div
> input:checked::after {
content: 'check';
font-family: 'Material Symbols Outlined Variable', sans-serif;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--c--contextuals--content--semantic--overlay--primary);
font-size: 18px;
}
/**
* Ensure consistent spacing between headings and paragraphs
*/
& .bn-block-outer:not(:first-child) {
&:has(h1) {
margin-top: 32px;

View File

@@ -34,12 +34,14 @@ import {
generateHtmlDocument,
improveHtmlAccessibility,
} from '../utils_html';
import { printDocumentWithStyles } from '../utils_print';
enum DocDownloadFormat {
HTML = 'html',
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
PRINT = 'print',
}
interface ModalExportProps {
@@ -66,6 +68,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setIsExporting(true);
// Handle print separately as it doesn't download a file
if (format === DocDownloadFormat.PRINT) {
printDocumentWithStyles();
setIsExporting(false);
onClose();
return;
}
const filename = (doc.title || untitledDocument)
.toLowerCase()
.normalize('NFD')
@@ -199,13 +209,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
</Button>
<Button
data-testid="doc-export-download-button"
aria-label={t('Download')}
aria-label={
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
}
variant="primary"
fullWidth
onClick={() => void onSubmit()}
disabled={isExporting}
>
{t('Download')}
{format === DocDownloadFormat.PRINT ? t('Print') : t('Download')}
</Button>
</>
}
@@ -225,7 +237,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
$align="flex-start"
data-testid="modal-export-title"
>
{t('Download')}
{t('Export')}
</Text>
<ButtonCloseModal
aria-label={t('Close the download modal')}
@@ -243,7 +255,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
>
<Text $variation="secondary" $size="sm" as="p">
{t(
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
)}
</Text>
<Select
@@ -251,10 +263,11 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
fullWidth
label={t('Format')}
options={[
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('HTML'), value: DocDownloadFormat.HTML },
{ label: t('Print'), value: DocDownloadFormat.PRINT },
]}
value={format}
onChange={(options) =>

View File

@@ -1,5 +1,7 @@
import JSZip from 'jszip';
import { isSafeUrl } from '@/utils/url';
import { exportResolveFileUrl } from './api';
// Escape user-provided text before injecting it into the exported HTML document.
@@ -11,6 +13,12 @@ export const escapeHtml = (value: string): string =>
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const moveChildNodes = (from: Element, to: Element) => {
while (from.firstChild) {
to.appendChild(from.firstChild);
}
};
/**
* Derives a stable, readable filename for media exported in the HTML ZIP.
*
@@ -138,7 +146,7 @@ export const improveHtmlAccessibility = (
const rawLevel = Number(block.getAttribute('data-level')) || 1;
const level = Math.min(Math.max(rawLevel, 1), 6);
const heading = parsedDocument.createElement(`h${level}`);
heading.innerHTML = block.innerHTML;
moveChildNodes(block, heading);
block.replaceWith(heading);
});
@@ -252,7 +260,7 @@ export const improveHtmlAccessibility = (
// Create list item and add content
const li = parsedDocument.createElement('li');
li.innerHTML = listItem.innerHTML;
moveChildNodes(listItem, li);
targetList.appendChild(li);
// Remove original block-outer
@@ -265,7 +273,7 @@ export const improveHtmlAccessibility = (
);
quoteBlocks.forEach((block) => {
const quote = parsedDocument.createElement('blockquote');
quote.innerHTML = block.innerHTML;
moveChildNodes(block, quote);
block.replaceWith(quote);
});
@@ -276,7 +284,7 @@ export const improveHtmlAccessibility = (
calloutBlocks.forEach((block) => {
const aside = parsedDocument.createElement('aside');
aside.setAttribute('role', 'note');
aside.innerHTML = block.innerHTML;
moveChildNodes(block, aside);
block.replaceWith(aside);
});
@@ -303,7 +311,7 @@ export const improveHtmlAccessibility = (
}
const li = parsedDocument.createElement('li');
li.innerHTML = item.innerHTML;
moveChildNodes(item, li);
// Ensure checkbox has an accessible state; fall back to aria-checked if missing.
const checkbox = li.querySelector<HTMLInputElement>(
@@ -340,7 +348,7 @@ export const improveHtmlAccessibility = (
});
// Move content inside <code>.
code.innerHTML = block.innerHTML;
moveChildNodes(block, code);
pre.appendChild(code);
block.replaceWith(pre);
});
@@ -408,7 +416,7 @@ export const addMediaFilesToZip = async (
url = null;
}
if (!url || url.origin !== mediaUrl) {
if (!url || url.origin !== mediaUrl || !isSafeUrl(url.href)) {
return;
}

View File

@@ -0,0 +1,266 @@
import { isSafeUrl } from '@/utils/url';
const PRINT_ONLY_CONTENT_STYLES_ID = 'print-only-content-styles';
const PRINT_APPLY_DELAY_MS = 200;
const PRINT_CLEANUP_DELAY_MS = 1000;
const PRINT_ONLY_CONTENT_CSS = `
@media print {
/* Reset body and html for proper pagination */
html, body {
height: auto !important;
overflow: visible !important;
background: var(--c--theme--colors--greyscale-000) !important;
margin: 0 !important;
padding: 0 !important;
}
/* Hide non-essential elements for printing */
.--docs--header,
.--docs--resizable-left-panel,
.--docs--doc-editor-header,
.--docs--doc-header,
.--docs--doc-toolbox,
.--docs--table-content,
.--docs--doc-footer,
.--docs--footer,
footer,
[role="contentinfo"],
div[data-is-empty-and-focused="true"],
div[data-floating-ui-focusable],
.collaboration-cursor-custom__base
{
display: none !important;
}
/* Hide selection highlights */
.ProseMirror-yjs-selection {
background-color: transparent !important;
}
/* Reset all layout containers for print flow */
.--docs--main-layout,
.--docs--main-layout > *,
main[role="main"],
#mainContent {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
background: var(--c--theme--colors--greyscale-000) !important;
margin: 0 !important;
padding: 0 !important;
}
/* Prevent any ancestor from clipping the end of the document */
.--docs--main-layout,
.--docs--main-layout * {
overflow: visible !important;
max-height: none !important;
}
/* Allow editor containers to flow across pages */
.--docs--editor-container,
.--docs--doc-editor,
.--docs--doc-editor-content {
max-width: 100% !important;
width: 100% !important;
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
padding: 0 !important;
margin: 0 !important;
}
/* Reset all Box components that might have height constraints */
.--docs--doc-editor > div,
.--docs--doc-editor-content > div {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
}
/* Ensure BlockNote content flows properly */
.bn-editor,
.bn-container,
.--docs--main-editor,
.bn-block-outer {
height: auto !important;
min-height: 0 !important;
max-height: none !important;
overflow: visible !important;
width: 100% !important;
max-width: 100% !important;
}
/* Hide media/embed placeholders and render their URLs */
[data-content-type="file"] .bn-file-block-content-wrapper,
[data-content-type="pdf"] .bn-file-block-content-wrapper,
[data-content-type="audio"] .bn-file-block-content-wrapper,
[data-content-type="video"] .bn-file-block-content-wrapper {
display: none !important;
}
div[data-page-break] {
opacity: 0;
}
/* Allow large blocks/media to split across pages */
.bn-block-content {
page-break-inside: avoid;
break-inside: avoid;
}
.--docs--main-editor {
width: 100% !important;
padding: 0.5cm !important;
}
/* Force print all colors and backgrounds */
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
/* Add minimal print margins */
@page {
margin: 0cm;
margin-bottom: 0.7cm;
margin-top: 0.7cm;
page-break-after: always;
}
.print-url-label {
text-decoration: none !important;
}
}
`;
/**
* Removes the print-only styles from the document head if they exist.
*/
function removePrintOnlyStyles() {
const stylesElement = document.getElementById(PRINT_ONLY_CONTENT_STYLES_ID);
if (stylesElement) {
stylesElement.remove();
}
}
/**
* Creates a style element containing CSS rules that are applied only during printing.
*/
function createPrintOnlyStyleElement() {
const printStyles = document.createElement('style');
printStyles.id = PRINT_ONLY_CONTENT_STYLES_ID;
printStyles.textContent = PRINT_ONLY_CONTENT_CSS;
return printStyles;
}
/**
* Removes any existing print-only styles and appends new ones to the document head.
*/
function appendPrintOnlyStyles() {
removePrintOnlyStyles();
document.head.appendChild(createPrintOnlyStyleElement());
return removePrintOnlyStyles;
}
/**
* Wraps media elements with links to their source URLs for printing.
*/
function wrapMediaWithLink() {
const createdShadowWrapper: HTMLElement[] = [];
const prependLink = (
el: Element,
url: string | null,
name: string | null,
type: 'file' | 'audio' | 'video' | 'pdf',
) => {
if (!url || !isSafeUrl(url)) {
return;
}
const block = document.createElement('div');
block.className = 'print-url-block-media';
const link = document.createElement('a');
link.className = 'print-url-link';
const label = document.createElement('span');
label.className = 'print-url-label';
if (type === 'audio') {
label.textContent = '🔊: ';
} else if (type === 'video') {
label.textContent = '📹: ';
} else if (type === 'pdf') {
label.textContent = '📑: ';
} else {
label.textContent = '🔗: ';
}
link.href = url;
link.textContent = name || url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.setAttribute('data-print-link', 'true');
block.appendChild(label);
block.appendChild(link);
const shadowWrapper = document.createElement('div');
el.prepend(shadowWrapper);
// Use a shadow root to avoid propagatic the changes to the collaboration provider
const shadowRoot = shadowWrapper.attachShadow({ mode: 'open' });
shadowRoot.appendChild(block);
createdShadowWrapper.push(shadowWrapper);
};
document
.querySelectorAll(
'[data-content-type="pdf"], [data-content-type="file"], [data-content-type="audio"], [data-content-type="video"]',
)
.forEach((el) => {
const url = el?.getAttribute('data-url');
const name = el?.getAttribute('data-name');
const type = el?.getAttribute('data-content-type') as
| 'file'
| 'audio'
| 'video'
| 'pdf';
if (type) {
prependLink(el, url, name, type);
}
});
return () => {
// remove the shadow roots that were created
createdShadowWrapper.forEach((link) => {
link.remove();
});
};
}
export function printDocumentWithStyles() {
if (typeof window === 'undefined') {
return;
}
const cleanupPrintStyles = appendPrintOnlyStyles();
// Small delay to ensure styles are applied
setTimeout(() => {
const cleanupLinks = wrapMediaWithLink();
const cleanup = () => {
cleanupLinks();
cleanupPrintStyles();
};
window.addEventListener('afterprint', cleanup, { once: true });
requestAnimationFrame(() => window.print());
// Also clean up after a delay as fallback
setTimeout(cleanup, PRINT_CLEANUP_DELAY_MS);
}, PRINT_APPLY_DELAY_MS);
}

View File

@@ -46,7 +46,9 @@ describe('DocToolBox - Licence', () => {
// Wait for the export modal to be visible, then assert on its content text.
await screen.findByTestId('modal-export-title');
expect(
screen.getByText(/Download your document in a .docx, .odt.*format\./i),
screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeInTheDocument();
}, 10000);

View File

@@ -49,7 +49,7 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
$variation="tertiary"
$size="s"
$weight="bold"
$theme={isEditable ? 'gray' : 'warning'}
$theme={isEditable ? 'neutral' : 'warning'}
>
{transRole(isEditable ? doc.user_role || doc.link_role : Role.READER)}
&nbsp;·&nbsp;

View File

@@ -1,9 +1,9 @@
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDate } from '@/hooks/useDate';
import { useResponsiveStore } from '@/stores';
import ChildDocument from '../assets/child-document.svg';
@@ -38,6 +38,7 @@ export const SimpleDocItem = ({
const { isDesktop } = useResponsiveStore();
const { untitledDocument } = useTrans();
const { isChild } = useDocUtils(doc);
const { relativeDate } = useDate();
return (
<Box
@@ -100,7 +101,7 @@ export const SimpleDocItem = ({
aria-hidden="true"
>
<Text $size="xs" $variation="tertiary">
{DateTime.fromISO(doc.updated_at).toRelative()}
{relativeDate(doc.updated_at)}
</Text>
</Box>
)}

View File

@@ -8,7 +8,7 @@ import { Base64 } from '../types';
export const useCollaboration = (room?: string, initialContent?: Base64) => {
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider } = useBroadcastStore();
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const { provider, createProvider, destroyProvider } = useProviderStore();
useEffect(() => {
@@ -33,8 +33,9 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
useEffect(() => {
return () => {
if (room) {
cleanupBroadcast();
destroyProvider();
}
};
}, [destroyProvider, room]);
}, [destroyProvider, room, cleanupBroadcast]);
};

View File

@@ -11,6 +11,7 @@ import {
QuickSearchData,
QuickSearchGroup,
} from '@/components/quick-search/';
import { useConfig } from '@/core';
import { Doc } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
@@ -57,6 +58,9 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const { t } = useTranslation();
const selectedUsersRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { data: config } = useConfig();
const API_USERS_SEARCH_QUERY_MIN_LENGTH =
config?.API_USERS_SEARCH_QUERY_MIN_LENGTH || 5;
const { isDesktop } = useResponsiveStore();
@@ -83,7 +87,6 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const canViewAccesses = doc.abilities.accesses_view;
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
const showFooter = selectedUsers.length === 0 && !inputValue;
const MIN_CHARACTERS_FOR_SEARCH = 4;
const onSelect = (user: User) => {
setSelectedUsers((prev) => [...prev, user]);
@@ -111,7 +114,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const searchUsersQuery = useUsers(
{ query: userQuery, docId: doc.id },
{
enabled: userQuery?.length > MIN_CHARACTERS_FOR_SEARCH,
enabled: userQuery?.length >= API_USERS_SEARCH_QUERY_MIN_LENGTH,
queryKey: [KEY_LIST_USER, { query: userQuery }],
},
);

View File

@@ -180,8 +180,8 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
options={linkReachOptions}
>
<Box
$theme={canManage ? 'brand' : 'gray'}
$variation={canManage ? 'tertiary' : 'primary'}
$theme={canManage ? 'brand' : 'neutral'}
$variation="tertiary"
$direction="row"
$align="center"
$gap={spacingsTokens['3xs']}
@@ -229,7 +229,11 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
}
label={t('Document access mode')}
>
<Text $weight="initial" $theme="brand" $variation="tertiary">
<Text
$weight="initial"
$theme={!canManage ? 'neutral' : 'brand'}
$variation={!canManage ? 'secondary' : 'tertiary'}
>
{linkModeTranslations[docLinkRole]}
</Text>
</DropdownMenu>

View File

@@ -13,9 +13,7 @@ const getDocVersion = async ({
versionId,
docId,
}: DocVersionParam): Promise<Version> => {
const url = `documents/${docId}/versions/${versionId}/`;
const response = await fetchAPI(url);
const response = await fetchAPI(`documents/${docId}/versions/${versionId}/`);
if (!response.ok) {
throw new APIError(

View File

@@ -29,9 +29,9 @@ const getDocVersions = async ({
versionId,
docId,
}: DocVersionsAPIParams): Promise<VersionsResponse> => {
const url = `documents/${docId}/versions/?version_id=${versionId}`;
const response = await fetchAPI(url);
const response = await fetchAPI(
`documents/${docId}/versions/?version_id=${versionId}`,
);
if (!response.ok) {
throw new APIError(

View File

@@ -237,7 +237,7 @@ const DocGridTitleBar = ({
>
<Box $direction="row" $gap="xs" $align="center">
{icon}
<Text as="h2" $size="h4" $margin="none">
<Text as="h2" $size="h4" $margin="none" tabIndex={-1}>
{title}
</Text>
</Box>

View File

@@ -223,7 +223,7 @@ export const DocsGridItemDate = ({
}
return (
<StyledLink href={`/docs/${doc.id}`}>
<StyledLink href={`/docs/${doc.id}`} tabIndex={-1}>
<Text
$size="xs"
$layer="background"

View File

@@ -31,6 +31,10 @@ describe('DocsGridItemDate', () => {
});
[
{
updated_at: DateTime.now().minus({ seconds: 1 }).toISO(),
rendered: 'just now',
},
{
updated_at: DateTime.now().minus({ minutes: 1 }).toISO(),
rendered: '1 minute ago',

View File

@@ -22,7 +22,7 @@ const BlueStripe = styled.div`
export const Footer = () => {
const { data: config } = useConfig();
const footerJson = config?.theme_customization?.footer;
const { i18n, t } = useTranslation();
const { i18n } = useTranslation();
const resolvedLanguage = i18n.resolvedLanguage;
const [content, setContent] = useState<ContentType>();
@@ -84,14 +84,12 @@ export const Footer = () => {
{logo?.src && (
<Image
priority
src={logo.src}
alt={logo?.alt || t('Logo')}
width={0}
height={0}
style={{ width: logo?.width || 'auto', height: 'auto' }}
{...(({ withTitle: _, ...rest }) => rest)(logo)}
/>
)}
{logo.withTitle && (
{logo?.withTitle && (
<Box $css="zoom:1.4;">
<Title />
</Box>

View File

@@ -1,28 +1,29 @@
import Image from 'next/image';
type Imagetype = React.ComponentProps<typeof Image>;
export interface FooterType {
default: ContentType;
[key: string]: ContentType;
}
export interface BottomInformation {
export interface BottomInformationType {
label: string;
link?: Link;
link?: LinkType;
}
export interface Link {
export interface LinkType {
label: string;
href: string;
}
export interface Logo {
src: string;
width: string;
alt: string;
export type LogoType = Imagetype & {
withTitle: boolean;
}
};
export interface ContentType {
logo?: Logo;
externalLinks?: Link[];
legalLinks?: Link[];
bottomInformation?: BottomInformation;
logo?: LogoType;
externalLinks?: LinkType[];
legalLinks?: LinkType[];
bottomInformation?: BottomInformationType;
}

View File

@@ -18,11 +18,10 @@ import { Waffle } from './Waffle';
export const Header = () => {
const { t } = useTranslation();
const { data: config } = useConfig();
const { spacingsTokens, componentTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const icon =
config?.theme_customization?.header?.icon || componentTokens.icon;
const icon = config?.theme_customization?.header?.icon;
return (
<>
@@ -68,19 +67,16 @@ export const Header = () => {
$height="fit-content"
$margin={{ top: 'auto' }}
>
<Image
data-testid="header-icon-docs"
src={icon.src || ''}
alt=""
width={0}
height={0}
style={{
width: icon.width,
height: icon.height,
}}
priority
/>
<Title headingLevel="h1" aria-hidden="true" />
{icon && (
<Image
data-testid="header-icon-docs"
width={0}
height={0}
priority
{...(({ withTitle: _, ...rest }) => rest)(icon)}
/>
)}
{icon?.withTitle && <Title headingLevel="h1" aria-hidden="true" />}
</Box>
</StyledLink>
{!isDesktop ? (

View File

@@ -1,7 +1,10 @@
import Image from 'next/image';
type Imagetype = React.ComponentProps<typeof Image> & {
withTitle: boolean;
};
export interface HeaderType {
icon?: {
src?: string;
width?: string;
height?: string;
};
logo?: Imagetype;
icon?: Imagetype;
}

View File

@@ -15,13 +15,11 @@ import { getHeaderHeight } from './HomeHeader';
export default function HomeBanner() {
const { t } = useTranslation();
const { componentTokens, spacingsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const { isMobile, isSmallMobile } = useResponsiveStore();
const withProConnect = componentTokens['home-proconnect'];
const { data: config } = useConfig();
const icon =
config?.theme_customization?.header?.icon || componentTokens.icon;
const withProConnect = config?.theme_customization?.home?.['with-proconnect'];
const icon = config?.theme_customization?.home?.['icon-banner'];
return (
<Box
@@ -50,18 +48,19 @@ export default function HomeBanner() {
$align="center"
$gap={spacingsTokens['sm']}
>
<Image
data-testid="header-icon-docs"
src={icon.src || ''}
alt=""
width={0}
height={0}
style={{
width: '64px',
height: 'auto',
}}
priority
/>
{icon?.src && (
<Image
data-testid="header-icon-docs"
width={0}
height={0}
style={{
width: '64px',
height: 'auto',
}}
priority
{...icon}
/>
)}
<Text
as="h2"
$size={!isMobile ? 'xs-alt' : '2.3rem'}

View File

@@ -2,14 +2,15 @@ import { useTranslation } from 'react-i18next';
import IconDocs from '@/assets/icons/icon-docs.svg';
import { Box, Text } from '@/components';
import { useConfig } from '@/core/config';
import { useCunninghamTheme } from '@/cunningham';
import { ProConnectButton } from '@/features/auth';
import { Title } from '@/features/header';
import { useResponsiveStore } from '@/stores';
export function HomeBottom() {
const { componentTokens } = useCunninghamTheme();
const withProConnect = componentTokens['home-proconnect'];
const { data: config } = useConfig();
const withProConnect = config?.theme_customization?.home?.['with-proconnect'];
if (!withProConnect) {
return null;

View File

@@ -15,13 +15,12 @@ export const getHeaderHeight = (isSmallMobile: boolean) =>
isSmallMobile ? HEADER_HEIGHT_MOBILE : HEADER_HEIGHT;
export const HomeHeader = () => {
const { spacingsTokens, componentTokens } = useCunninghamTheme();
const logo = componentTokens.logo;
const { spacingsTokens } = useCunninghamTheme();
const { isSmallMobile } = useResponsiveStore();
const { data: config } = useConfig();
const icon =
config?.theme_customization?.header?.icon || componentTokens.icon;
const icon = config?.theme_customization?.header?.icon;
const logo = config?.theme_customization?.header?.logo;
return (
<Box
@@ -49,11 +48,10 @@ export const HomeHeader = () => {
{!isSmallMobile && logo?.src && (
<Image
priority
src={logo.src}
alt={logo.alt}
width={0}
height={0}
style={{ width: logo.widthHeader, height: 'auto' }}
style={{ width: logo.width, height: 'auto' }}
{...logo}
/>
)}
<Box
@@ -63,19 +61,20 @@ export const HomeHeader = () => {
$position="relative"
$height="fit-content"
>
<Image
data-testid="header-icon-docs"
src={icon.src || ''}
alt=""
width={0}
height={0}
style={{
width: icon.width,
height: icon.height,
}}
priority
/>
<Title />
{icon && (
<Image
data-testid="header-icon-docs"
width={0}
height={0}
style={{
width: icon.width,
height: icon.height,
}}
priority
{...(({ withTitle: _, ...rest }) => rest)(icon)}
/>
)}
{icon?.withTitle && <Title />}
</Box>
</Box>
{!isSmallMobile && (

View File

@@ -70,7 +70,9 @@ export const LeftPanelTargetFilters = () => {
href={href}
aria-label={query.label}
aria-current={isActive ? 'page' : undefined}
onClick={handleClick}
onClick={() => {
handleClick();
}}
$css={css`
display: flex;
align-items: center;

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