Compare commits

..

31 Commits

Author SHA1 Message Date
Anthony LC
9aa0cb7788 ⬆️(docker) upgrade to recommended version
Docker DX linter and trivy were pointing
vulnerabilities in our Dockerfile.
We bumped the base image to a more secure version.
2025-05-15 16:13:06 +02:00
Anthony LC
dd6e0b5072 📝(project) add missing env var to env.md
Update the documentation to include the
missing environment variables.
The missing environment variables are involved
in the build process of the frontend image.
2025-05-13 22:26:08 +02:00
Anthony LC
95d3a8cd18 ✏️(project) automatic typo correction
Fix typos in the project.
2025-05-13 16:00:43 +02:00
Anthony LC
4f126ab824 🩺(CI) add lint spell mistakes
We get lot of pull requests about typo.
We add codespell linter in the CI, it will inform
us if we introduce spell mistakes.
2025-05-13 16:00:43 +02:00
Manuel Raynaud
fb90c13dad ♻️(helm) change default customization CM mount path
The mount path used in the backend deployment to mount the customization
file ConfigMap is not the same from the default settings. To avoid extra
configuration we change it to refrlect the default value of
settings.THEME_CUSTOMIZATION_FILE_PATH
2025-05-13 15:19:55 +02:00
Manuel Raynaud
4118d79525 🔧(helm) add celery deployment
We need to configure a deployment dedicated to celery. It is a copy of
the backend one with modification made where it is specific to celery
2025-05-13 15:19:54 +02:00
renovate[bot]
5848f43cb4 ⬆️(dependencies) update python dependencies (#956) 2025-05-12 14:29:04 +00:00
Manuel Raynaud
4b0fd223c8 🐛(back) override AI feature flag in config test
The env.d/development/common file sets
AI_FEATURE_ENABLED=true.
When pytest starts it imports these variables, so
the /api/v1.0/config endpoint returns
AI_FEATURE_ENABLED=True and the test_api_config
assertion fails.

Explicitly overriding AI_FEATURE_ENABLED=False in
test_api_config restores the expected behaviour
and makes the whole test-suite green.

Signed-off-by: ReinforcedKnowledge <reinforced.knowledge@gmail.com>
2025-05-12 15:56:30 +02:00
Manuel Raynaud
31d0733851 🔧(back) configure cache key prefix
We want to change the cache key prefix using an environment variable.
This settings can be changed at every deployment in order to reset to
use a fresh new cache.
2025-05-12 15:56:29 +02:00
Manuel Raynaud
16e20e984c (helm) allow to load custom theme file in a configMap
In order to load a custom theme file with our helm chart, we allow to
load the content of a file into a config map and then use this configmap
as a volume in the backend deployment
2025-05-12 15:56:29 +02:00
Manuel Raynaud
76c28760dc 🔥(back) remove footer endpoint
With the configuration file, the footer endpoint can be removed and will
not be used anymore by the front application.
2025-05-12 15:56:29 +02:00
Manuel Raynaud
d856abb5d8 (back) allow theme customnization using a configuration file
We want to customize the theme by using a configuration file. This
configuration file path can be defined using the settings
THEME_CUSTOMIZATION_FILE_PATH. If this file does not exists or is an
invalid json, an empty json object will be added in the config endpoint.
2025-05-12 15:56:26 +02:00
Manuel Raynaud
25abd964de (backend) manage uploaded file status and call to malware detection
In the attachment_upload method, the status in the file metadata to
processing and the malware_detection backend is called. We check in the
media_auth if the status is ready in order to accept the request.
2025-05-12 15:14:09 +02:00
Manuel Raynaud
a070e1dd87 (backend) configure lasuite.malware_detection module
We want to use the malware_detection module from lasuite library. We add
a new setting MALWARE_DETECTION to configure the backend we want to use.
The callback is also added. It removes the file if it is not safe or
change it's status in the metadata to set it as ready.
2025-05-12 15:13:33 +02:00
Manuel Raynaud
37d9ae8cca (backend) force loading celery shared task in libraries
Library we are using can have celery shared task. We have to make some
modification to load them earlier when the celery app is configure and
when the impress app is loaded.
2025-05-12 15:13:32 +02:00
Zorin95670
29ea6b8ef7 (frontend) Improve test coverage
Improve the test coverage of the "api" modules.

Signed-off-by: Zorin95670 <moittie.vincent@gmail.com>
2025-05-12 14:07:08 +02:00
Zorin95670
a692fa6f39 📝(frontend) Update documentation
Improve and add jsdoc.

Signed-off-by: Zorin95670 <moittie.vincent@gmail.com>
2025-05-12 14:07:08 +02:00
Zorin95670
4d541c5d52 🎨(frontend) Minor refactoring
- improve condition statements
- add "no-var" rule in eslint
- remove some unnecessary variables

Signed-off-by: Zorin95670 <moittie.vincent@gmail.com>
2025-05-12 14:07:08 +02:00
Anthony LC
e5f029ad1d 🚩(frontend) version MIT only
We have some packages that are not MIT compatible,
so if the env var MIT_ONLY is set to true,
we don't build the application with features
that are not MIT compatible.
For the moment, it concerns only the export packages.
2025-05-12 12:00:59 +02:00
ZouicheOmar
bd79f84e07 (frontend) adapt export to callout block
Adapt modal export to include PDF and Docx export
for the callout block.
2025-05-12 09:30:17 +02:00
ZouicheOmar
a070f56339 (frontend) add custom callout block to editor
Add a custom block to the editor, the callout block.
2025-05-12 09:30:17 +02:00
ZouicheOmar
02478acb3f (frontend) add emoji picker component
Add a custom emoji picker component to use in the editor
2025-05-12 09:30:17 +02:00
ZouicheOmar
23aa497db0 (frontend) add emoji-mart packages
We need functionalities and data to implement a custom emoji picker
component, as blocknote's emojipicker component triggers and uses cases
are limited.
add to package.json the following packages:
- "emoji-mart": provides functions and components for
displaying, searching and selecting emojis.
- @emoji-mart-data: offers pre-configured sets of emojis.
- @emoji-mart/react: React Picker component
2025-05-12 09:29:04 +02:00
virgile-dev
d48436bffb 📝(doc) complete contributing policy (#895)
We made mandatory signing commits.
Provided warnings for common gitmoji errors

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-05-09 20:40:44 +00:00
renovate[bot]
41e4c45934 ⬆️(dependencies) update django to v5.1.9 [SECURITY] (#953) 2025-05-09 16:26:57 +02:00
Anthony LC
6be87ed477 🔖(patch) release 3.2.1
Fixed:
- 🐛(frontend) fix list copy paste
2025-05-07 10:27:39 +02:00
Anthony LC
c96182b3e3 🐛(frontend) fix list copy paste
When we copy paste a list, the pasted
list is not formatted correctly.
By pinning prosemirror-model to 1.25.0,
we avoid this issue.
We added "prosemirror-model" to the
ignored dependencies of Renovate to
avoid to have a bump until the patch
on the Blocknote.js side.
2025-05-07 10:25:48 +02:00
Anthony LC
e79d1d618a ⬆️(dependencies) update js dependencies 2025-05-06 11:51:24 +02:00
renovate[bot]
2691cdd4a2 ⬆️(dependencies) update python dependencies (#934) 2025-05-06 09:35:31 +00:00
Riël Notermans
05a1390bdc 📝(doc) Update env.md add AI_FEATURE_ENABLED
This is false by default.
Without this env setting on true AI will not be available in the
docs application.
The setting was missing in the env options.
2025-05-06 10:54:18 +02:00
Anthony LC
dfe8ae14fe 🐛(docker-compose) unbind the y-provider service with frontend
We cannot add new js dependency locally when we bind the
frontend with the y-provider service. It results in
"EPERM: operation not permitted" when the `node_modules`
has to be updated.
Better to remove the binding, we can add the binding
locally during development on the y-provider.
2025-05-06 10:35:59 +02:00
109 changed files with 2550 additions and 994 deletions

View File

@@ -79,7 +79,9 @@ jobs:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -61,6 +61,25 @@ jobs:
exit 1
fi
lint-spell-mistakes:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install codespell
run: pip install --user codespell
- name: Check for typos
run: |
codespell \
--check-filenames \
--ignore-words-list "Dokument,afterAll,excpt,statics" \
--skip "./git/" \
--skip "**/*.po" \
--skip "**/*.pot" \
--skip "**/*.json" \
--skip "**/yarn.lock"
lint-back:
runs-on: ubuntu-latest
defaults:

View File

@@ -8,12 +8,38 @@ and this project adheres to
## [Unreleased]
## Added
- ✨(back) allow theme customnization using a configuration file #948
- ✨ Add a custom callout block to the editor #892
- 🚩(frontend) version MIT only #911
- ✨(backend) integrate maleware_detection from django-lasuite #936
- 🩺(CI) add lint spell mistakes #954
## Changed
- 📝(frontend) Update documentation
- ✅(frontend) Improve tests coverage
### Removed
- 🔥(back) remove footer endpoint
## [3.2.1] - 2025-05-06
## Fixed
- 🐛(frontend) fix list copy paste #943
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
## [3.2.0] - 2025-05-05
## Added
- 🚸(backend) make document search on title accent-insensitive #874
- 🚩 add homepage feature flag #861
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
- ✨(settings) Allow configuring PKCE for the SSO #886
- 🌐(i18n) activate chinese and spanish languages #884
- 🔧(backend) allow overwriting the data directory #893
@@ -531,7 +557,7 @@ and this project adheres to
- ⚡️(e2e) unique login between tests (#80)
- ⚡️(CI) improve e2e job (#86)
- ♻️(frontend) improve the error and message info ui (#93)
- ✏️(frontend) change all occurences of pad to doc (#99)
- ✏️(frontend) change all occurrences of pad to doc (#99)
## Fixed
@@ -549,7 +575,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.2.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.2.1...main
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0

View File

@@ -2,16 +2,16 @@
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions on how to run Docs locally.
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Help us with translations
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
Your language is not there? Request it on our Crowdin page 😊.
Your language is not there? Request it on our Crowdin page 😊 or ping us on [Matrix](https://matrix.to/#/#docs-official:matrix.org) and let us know if you can help with translations and/or proofreading.
## Creating an Issue
@@ -35,10 +35,14 @@ All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change.
* **description**: Include additional details about what was changed and why.
* **title**: A short, descriptive title for the change (*)
* **blank line after the commit title
* **description**: Include additional details on why you made the changes (**).
(*) ⚠️ **Make sure you add no space between the emoji and the (type) but add a space after the closing parenthesis of the type and use no caps!**
(**) ⚠️ **Commit description message is mandatory and shouldn't be too long**
### Example Commit Message
@@ -66,7 +70,9 @@ Please add a line to the changelog describing your development. The changelog en
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- signoff your commits
- sign your commits with your key (SSH, GPG etc.)
- check your commits (see warnings above)
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
@@ -86,3 +92,11 @@ Make sure that all new features or fixes have corresponding tests. Run the test
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍
## Contribute to BlockNote
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).

View File

@@ -1,7 +1,7 @@
# Django impress
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.12.10-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
@@ -30,12 +30,13 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:24-alpine AS mail-builder
COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN apk update && apk add --no-cache bash
RUN yarn install --frozen-lockfile && \
yarn build

View File

@@ -4,7 +4,7 @@
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
If you have any issue regarding security, please disclose the information responsibly submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
We appreciate your effort to make Docs more secure.

View File

@@ -155,8 +155,7 @@ services:
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
Y_PROVIDER_URL: "ws://localhost:4444"
MEDIA_URL: "http://localhost:8083"
PUBLISH_AS_MIT: "false"
SW_DEACTIVATED: "true"
image: impress:frontend-development
ports:
@@ -185,15 +184,11 @@ services:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
command: ["yarn", "workspace", "server-y-provider", "run", "dev"]
working_dir: /app/frontend
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
volumes:
- ./src/frontend/:/app/frontend
kc_postgresql:
image: postgres:14.3

View File

@@ -4,7 +4,7 @@ Here we describe all environment variables that can be set for the docs applicat
## impress-backend container
These are the environmental variables you can set for the impress-backend container.
These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
@@ -39,7 +39,7 @@ These are the environmental variables you can set for the impress-backend contai
| DJANGO_EMAIL_PORT | port used to connect to email host | |
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
| DJANGO_EMAIL_FROM | email adress used as sender | from@example.com |
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
@@ -49,9 +49,6 @@ These are the environmental variables you can set for the impress-backend contai
| COLLABORATION_WS_URL | collaboration websocket url | |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
| FRONTEND_FOOTER_FEATURE_ENABLED | frontend feature flag to display the footer | false |
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
| FRONTEND_THEME | frontend theme to use | |
| POSTHOG_KEY | posthog key for analytics | |
| CRISP_WEBSITE_ID | crisp website id for support | |
@@ -62,11 +59,11 @@ These are the environmental variables you can set for the impress-backend contai
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Autorization endpoint for OIDC | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth paramaters | {} |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
| LOGIN_REDIRECT_URL | login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
@@ -76,7 +73,7 @@ These are the environmental variables you can set for the impress-backend contai
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow dupplicate emails | false |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
@@ -85,6 +82,7 @@ These are the environmental variables you can set for the impress-backend contai
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_MODEL | AI Model to use | |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_FEATURE_ENABLED | Enable AI options | false |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
@@ -97,3 +95,19 @@ These are the environmental variables you can set for the impress-backend contai
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| REDIS_URL | cache url | redis://redis:6379/1 |
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| 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 |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
## impress-frontend image
These are the environment variables you can set to build the `impress-frontend` image.
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
| SW_DEACTIVATED | To not install the service worker | |
| PUBLISH_AS_MIT | MIT licence does not include the export feature | true |

View File

@@ -82,13 +82,13 @@ backend:
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:

View File

@@ -133,7 +133,7 @@ OIDC_RP_SCOPES: "openid email"
You can find these values in **examples/keycloak.values.yaml**
### Find redis server connexion values
### Find redis server connection values
Docs needs a redis so we start by deploying one:
@@ -146,7 +146,7 @@ keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
### Find postgresql connexion values
### Find postgresql connection values
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
@@ -173,7 +173,7 @@ POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
### Find s3 bucket connexion values
### Find s3 bucket connection values
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
@@ -191,7 +191,7 @@ redis-master-0 1/1 Running 0 10m
## Deployment
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous informations to the helm chart.
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous information to the helm chart.
```
$ helm repo add impress https://suitenumerique.github.io/docs/

View File

@@ -64,5 +64,3 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
# Frontend
FRONTEND_THEME=default
FRONTEND_FOOTER_FEATURE_ENABLED=True
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json

View File

@@ -4,3 +4,4 @@ BURST_THROTTLE_RATES="200/minute"
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
Y_PROVIDER_API_KEY=yprovider-api-key
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
THEME_CUSTOMIZATION_FILE_PATH="" #force theme_customization to be empty

View File

@@ -11,14 +11,16 @@
},
{
"groupName": "allowed django versions",
"matchManagers": [
"pep621"
],
"matchPackageNames": [
"Django"
],
"matchManagers": ["pep621"],
"matchPackageNames": ["Django"],
"allowedVersions": "<5.2"
},
{
"groupName": "allowed redis versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"],
"allowedVersions": "<6.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
@@ -26,6 +28,7 @@
"matchPackageNames": [
"eslint",
"fetch-mock",
"prosemirror-model",
"node",
"node-fetch",
"workbox-webpack-plugin"

View File

@@ -1,6 +1,7 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import json
import logging
import uuid
from urllib.parse import unquote, urlparse
@@ -9,6 +10,7 @@ from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import connection, transaction
@@ -16,14 +18,13 @@ from django.db import models as db
from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.utils.decorators import method_decorator
from django.utils.text import capfirst
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page
import requests
import rest_framework as drf
from botocore.exceptions import ClientError
from lasuite.malware_detection import malware_detection
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
@@ -32,7 +33,6 @@ from rest_framework.throttling import UserRateThrottle
from core import authentication, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.config_services import get_footer_json
from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
@@ -576,7 +576,7 @@ class DocumentViewSet(
queryset, filter_data["is_favorite"]
)
# Apply ordering only now that everyting is filtered and annotated
# Apply ordering only now that everything is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
self.request, queryset, self
)
@@ -889,7 +889,7 @@ class DocumentViewSet(
)
# Compute cache for ancestors links to avoid many queries while computing
# abilties for his documents in the tree!
# abilities for his documents in the tree!
ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
)
@@ -1156,7 +1156,10 @@ class DocumentViewSet(
# Prepare metadata for storage
extra_args = {
"Metadata": {"owner": str(request.user.id)},
"Metadata": {
"owner": str(request.user.id),
"status": enums.DocumentAttachmentStatus.PROCESSING,
},
"ContentType": serializer.validated_data["content_type"],
}
file_unsafe = ""
@@ -1188,6 +1191,8 @@ class DocumentViewSet(
document.attachments.append(key)
document.save()
malware_detection.analyse_file(key, document_id=document.id)
return drf.response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
status=drf.status.HTTP_201_CREATED,
@@ -1271,6 +1276,19 @@ class DocumentViewSet(
logger.debug("User '%s' lacks permission for attachment", user)
raise drf.exceptions.PermissionDenied()
# Check if the attachment is ready
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
metadata = head_resp.get("Metadata", {})
# In order to be compatible with existing upload without `status` metadata,
# we consider them as ready.
if (
metadata.get("status", enums.DocumentAttachmentStatus.READY)
!= enums.DocumentAttachmentStatus.READY
):
raise drf.exceptions.PermissionDenied()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(key)
@@ -1715,7 +1733,6 @@ class ConfigView(drf.views.APIView):
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_FOOTER_FEATURE_ENABLED",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
@@ -1728,23 +1745,41 @@ class ConfigView(drf.views.APIView):
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
dict_settings["theme_customization"] = self._load_theme_customization()
return drf.response.Response(dict_settings)
def _load_theme_customization(self):
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
return {}
class FooterView(drf.views.APIView):
"""API ViewSet for sharing the footer JSON."""
permission_classes = [AllowAny]
@method_decorator(cache_page(settings.FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT))
def get(self, request):
"""
GET /api/v1.0/footer/
Return the footer JSON.
"""
json_footer = (
get_footer_json(settings.FRONTEND_URL_JSON_FOOTER)
if settings.FRONTEND_URL_JSON_FOOTER
else {}
cache_key = (
f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}"
)
return drf.response.Response(json_footer)
theme_customization = cache.get(cache_key, {})
if theme_customization:
return theme_customization
try:
with open(
settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8"
) as f:
theme_customization = json.load(f)
except FileNotFoundError:
logger.error(
"Configuration file not found: %s",
settings.THEME_CUSTOMIZATION_FILE_PATH,
)
except json.JSONDecodeError:
logger.error(
"Configuration file is not a valid JSON: %s",
settings.THEME_CUSTOMIZATION_FILE_PATH,
)
else:
cache.set(
cache_key,
theme_customization,
settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT,
)
return theme_customization

View File

@@ -3,6 +3,7 @@ Core application enums declaration
"""
import re
from enum import StrEnum
from django.conf import global_settings, settings
from django.db import models
@@ -38,3 +39,10 @@ class MoveNodePositionChoices(models.TextChoices):
LAST_SIBLING = "last-sibling", _("Last sibling")
LEFT = "left", _("Left")
RIGHT = "right", _("Right")
class DocumentAttachmentStatus(StrEnum):
"""Defines the possible statuses for an attachment."""
PROCESSING = "processing"
READY = "ready"

View File

@@ -0,0 +1,52 @@
"""Malware detection callbacks"""
import logging
from django.core.files.storage import default_storage
from lasuite.malware_detection.enums import ReportStatus
from core.enums import DocumentAttachmentStatus
from core.models import Document
logger = logging.getLogger(__name__)
security_logger = logging.getLogger("docs.security")
def malware_detection_callback(file_path, status, error_info, **kwargs):
"""Malware detection callback"""
if status == ReportStatus.SAFE:
logger.info("File %s is safe", file_path)
# Get existing metadata
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
head_resp = s3_client.head_object(Bucket=bucket_name, Key=file_path)
metadata = head_resp.get("Metadata", {})
metadata.update({"status": DocumentAttachmentStatus.READY})
# Update status in metadata
s3_client.copy_object(
Bucket=bucket_name,
CopySource={"Bucket": bucket_name, "Key": file_path},
Key=file_path,
ContentType=head_resp.get("ContentType"),
Metadata=metadata,
MetadataDirective="REPLACE",
)
return
document_id = kwargs.get("document_id")
security_logger.warning(
"File %s for document %s is infected with malware. Error info: %s",
file_path,
document_id,
error_info,
)
# Remove the file from the document and change the status to unsafe
document = Document.objects.get(pk=document_id)
document.attachments.remove(file_path)
document.save(update_fields=["attachments"])
# Delete the file from the storage
default_storage.delete(file_path)

View File

@@ -44,7 +44,7 @@ AI_ACTIONS = {
}
AI_TRANSLATE = (
"Keep the same html stucture and formatting. "
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language {language:s}. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."

View File

@@ -17,7 +17,7 @@ class CollaborationService:
def reset_connections(self, room, user_id=None):
"""
Reset connections of a room in the collaboration server.
Reseting a connection means that the user will be disconnected and will
Resetting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
"""
endpoint = "reset-connections"

View File

@@ -1,25 +0,0 @@
"""Config services."""
import logging
import requests
logger = logging.getLogger(__name__)
def get_footer_json(footer_json_url: str) -> dict:
"""
Fetches the footer JSON from the given URL."
"""
try:
response = requests.get(
footer_json_url, timeout=5, headers={"User-Agent": "Docs-Application"}
)
response.raise_for_status()
footer_json = response.json()
return footer_json
except (requests.RequestException, ValueError) as e:
logger.error("Failed to fetch footer JSON: %s", e)
return {}

View File

@@ -575,7 +575,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an exising identity in the db
# Build an invitation to the email of an existing identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.values),

View File

@@ -150,7 +150,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Autenticated who are not related to a document should be able to request AI transform
Authenticated who are not related to a document should be able to request AI transform
if the link reach and role permit it.
"""
user = factories.UserFactory()

View File

@@ -99,7 +99,7 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
{
"role": "system",
"content": (
"Keep the same html stucture and formatting. "
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
@@ -172,7 +172,7 @@ def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Autenticated who are not related to a document should be able to request AI translate
Authenticated who are not related to a document should be able to request AI translate
if the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -197,7 +197,7 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
{
"role": "system",
"content": (
"Keep the same html stucture and formatting. "
"Keep the same html structure and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
@@ -274,7 +274,7 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
{
"role": "system",
"content": (
"Keep the same html stucture and formatting. "
"Keep the same html structure and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "

View File

@@ -4,6 +4,7 @@ Test file uploads API endpoint for users in impress's core app.
import re
import uuid
from unittest import mock
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -12,6 +13,7 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.viewsets import malware_detection
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -59,7 +61,8 @@ def test_api_documents_attachment_upload_anonymous_success():
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 201
@@ -74,12 +77,13 @@ def test_api_documents_attachment_upload_anonymous_success():
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
key = file_path.replace("/media/", "")
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": "None"}
assert file_head["Metadata"] == {"owner": "None", "status": "processing"}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
@@ -127,7 +131,7 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Autenticated users who are not related to a document should be able to upload
Authenticated users who are not related to a document should be able to upload
a file when the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -139,7 +143,8 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
@@ -147,6 +152,10 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
match = pattern.search(response.json()["file"])
file_id = match.group(1)
mock_analyse_file.assert_called_once_with(
f"{document.id!s}/attachments/{file_id!s}.png", document_id=document.id
)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
@@ -210,7 +219,8 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
@@ -226,11 +236,12 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
key = file_path.replace("/media/", "")
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["Metadata"] == {"owner": str(user.id), "status": "processing"}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
@@ -255,7 +266,7 @@ def test_api_documents_attachment_upload_invalid(client):
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceeed the maximum size in settings."""
"""The uploaded file should not exceed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()
@@ -304,7 +315,8 @@ def test_api_documents_attachment_upload_fix_extension(
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(name=name, content=content)
response = client.post(url, {"file": file}, format="multipart")
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
@@ -324,11 +336,16 @@ def test_api_documents_attachment_upload_fix_extension(
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
key = file_path.replace("/media/", "")
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["Metadata"] == {
"owner": str(user.id),
"is_unsafe": "true",
"status": "processing",
}
assert file_head["ContentType"] == content_type
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
@@ -364,7 +381,8 @@ def test_api_documents_attachment_upload_unsafe():
file = SimpleUploadedFile(
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
)
response = client.post(url, {"file": file}, format="multipart")
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
@@ -381,11 +399,16 @@ def test_api_documents_attachment_upload_unsafe():
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
key = file_path.replace("/media/", "")
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["Metadata"] == {
"owner": str(user.id),
"is_unsafe": "true",
"status": "processing",
}
assert file_head["ContentType"] == "application/octet-stream"
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'

View File

@@ -279,7 +279,7 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback
"""
It should be possible to create a document on behalf of a pre-existing user for
who the sub was not found if the settings allow it. This edge case should not
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
happen in a healthy OIDC federation but can be useful if an OIDC provider modifies
users sub on each login for example...
"""
user = factories.UserFactory(language="en-us")

View File

@@ -15,6 +15,7 @@ import requests
from rest_framework.test import APIClient
from core import factories, models
from core.enums import DocumentAttachmentStatus
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -45,6 +46,7 @@ def test_api_documents_media_auth_anonymous_public():
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.READY},
)
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
@@ -93,7 +95,15 @@ def test_api_documents_media_auth_extensions():
keys = []
for ext in extensions:
filename = f"{uuid4()!s}.{ext:s}"
keys.append(f"{document_id!s}/attachments/{filename:s}")
key = f"{document_id!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.READY},
)
keys.append(key)
factories.DocumentFactory(link_reach="public", attachments=keys)
@@ -142,6 +152,7 @@ def test_api_documents_media_auth_anonymous_attachments():
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.READY},
)
factories.DocumentFactory(id=document_id, link_reach="restricted")
@@ -205,6 +216,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.READY},
)
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
@@ -283,6 +295,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.READY},
)
document = factories.DocumentFactory(
@@ -321,3 +334,70 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_media_auth_not_ready_status():
"""Attachments with status not ready should not be accessible"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.PROCESSING},
)
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_documents_media_auth_missing_status_metadata():
"""Attachments without status metadata should be considered as ready"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"

View File

@@ -310,7 +310,7 @@ def test_api_documents_move_authenticated_deleted_target_as_child(position):
def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
"""
It should not be possible to move a document as a sibling of a deleted target document
if the user has no rigths on its parent.
if the user has no rights on its parent.
"""
user = factories.UserFactory()
client = APIClient()

View File

@@ -31,7 +31,7 @@ def get_ydoc_with_mages(image_keys):
def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_queries):
"""
When an anonymous user updates a document, the attachment keys extracted from the
updated content should be added to the list of "attachments" ot the document if these
updated content should be added to the list of "attachments" to the document if these
attachments are already readable by anonymous users.
"""
image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(4)]
@@ -78,7 +78,7 @@ def test_api_documents_update_new_attachment_keys_authenticated(
):
"""
When an authenticated user updates a document, the attachment keys extracted from the
updated content should be added to the list of "attachments" ot the document if these
updated content should be added to the list of "attachments" to the document if these
attachments are already readable by the editing user.
"""
user = factories.UserFactory()

View File

@@ -2,6 +2,8 @@
Test config API endpoints in the Impress core app.
"""
import json
from django.test import override_settings
import pytest
@@ -16,14 +18,15 @@ pytestmark = pytest.mark.django_db
@override_settings(
AI_FEATURE_ENABLED=False,
COLLABORATION_WS_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_FOOTER_FEATURE_ENABLED=True,
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
SENTRY_DSN="https://sentry.test/123",
THEME_CUSTOMIZATION_FILE_PATH="",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
@@ -42,7 +45,6 @@ def test_api_config(is_authenticated):
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_FOOTER_FEATURE_ENABLED": True,
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
@@ -56,4 +58,98 @@ def test_api_config(is_authenticated):
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
"AI_FEATURE_ENABLED": False,
"theme_customization": {},
}
@override_settings(
THEME_CUSTOMIZATION_FILE_PATH="/not/existing/file.json",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config_with_invalid_theme_customization_file(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["theme_customization"] == {}
@override_settings(
THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/invalid.json",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config_with_invalid_json_theme_customization_file(is_authenticated, fs):
"""Anonymous users should be allowed to get the configuration."""
fs.create_file(
"/configuration/theme/invalid.json",
contents="invalid json",
)
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["theme_customization"] == {}
@override_settings(
THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/default.json",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config_with_theme_customization(is_authenticated, fs):
"""Anonymous users should be allowed to get the configuration."""
fs.create_file(
"/configuration/theme/default.json",
contents=json.dumps(
{
"colors": {
"primary": "#000000",
"secondary": "#000000",
},
}
),
)
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["theme_customization"] == {
"colors": {
"primary": "#000000",
"secondary": "#000000",
},
}
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config_with_original_theme_customization(is_authenticated, settings):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
content = response.json()
with open(settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8") as f:
theme_customization = json.load(f)
assert content["theme_customization"] == theme_customization

View File

@@ -1,81 +0,0 @@
"""Test the footer API."""
import responses
from rest_framework.test import APIClient
def test_api_footer_without_settings_configured(settings):
"""Test the footer API without settings configured."""
settings.FRONTEND_URL_JSON_FOOTER = None
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
@responses.activate
def test_api_footer_with_invalid_request(settings):
"""Test the footer API with an invalid request."""
settings.FRONTEND_URL_JSON_FOOTER = "https://invalid-request.com"
footer_response = responses.get(settings.FRONTEND_URL_JSON_FOOTER, status=404)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_invalid_json(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, body="invalid json"
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_valid_json(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_valid_json_and_cache(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
assert footer_response.call_count == 1
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
# The cache should have been used
assert footer_response.call_count == 1

View File

@@ -0,0 +1,76 @@
"""Test malware detection callback."""
import random
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
import pytest
from lasuite.malware_detection.enums import ReportStatus
from core.enums import DocumentAttachmentStatus
from core.factories import DocumentFactory
from core.malware_detection import malware_detection_callback
pytestmark = pytest.mark.django_db
@pytest.fixture(name="safe_file")
def fixture_safe_file():
"""Create a safe file."""
file_path = "test.txt"
default_storage.save(file_path, ContentFile("test"))
yield file_path
default_storage.delete(file_path)
@pytest.fixture(name="unsafe_file")
def fixture_unsafe_file():
"""Create an unsafe file."""
file_path = "unsafe.txt"
default_storage.save(file_path, ContentFile("test"))
yield file_path
def test_malware_detection_callback_safe_status(safe_file):
"""Test malware detection callback with safe status."""
document = DocumentFactory(attachments=[safe_file])
malware_detection_callback(
safe_file,
ReportStatus.SAFE,
error_info={},
document_id=document.id,
)
document.refresh_from_db()
assert safe_file in document.attachments
assert default_storage.exists(safe_file)
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
head_resp = s3_client.head_object(Bucket=bucket_name, Key=safe_file)
metadata = head_resp.get("Metadata", {})
assert metadata["status"] == DocumentAttachmentStatus.READY
def test_malware_detection_callback_unsafe_status(unsafe_file):
"""Test malware detection callback with unsafe status."""
document = DocumentFactory(attachments=[unsafe_file])
malware_detection_callback(
unsafe_file,
random.choice(
[status.value for status in ReportStatus if status != ReportStatus.SAFE]
),
error_info={"error": "test", "error_code": 4001},
document_id=document.id,
)
document.refresh_from_db()
assert unsafe_file not in document.attachments
assert not default_storage.exists(unsafe_file)

View File

@@ -56,5 +56,4 @@ urlpatterns = [
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
path(f"api/{settings.API_VERSION}/footer/", viewsets.FooterView.as_view()),
]

View File

@@ -0,0 +1,5 @@
"""Impress package. Import the celery app early to load shared task form dependencies."""
from .celery_app import app as celery_app
__all__ = ["celery_app"]

View File

@@ -11,6 +11,9 @@ os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
install(check_options=True)
# Can not be loaded only after install call.
from django.conf import settings # pylint: disable=wrong-import-position
app = Celery("impress")
# Using a string here means the worker doesn't have to serialize
@@ -20,4 +23,4 @@ app = Celery("impress")
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps.
app.autodiscover_tasks()
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)

View File

@@ -0,0 +1,124 @@
{
"footer": {
"default": {
"externalLinks": [
{
"label": "Github",
"href": "https://github.com/suitenumerique/docs/"
},
{
"label": "DINUM",
"href": "https://www.numerique.gouv.fr/dinum/"
},
{
"label": "ZenDiS",
"href": "https://zendis.de/"
},
{
"label": "BlockNote.js",
"href": "https://www.blocknotejs.org/"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"en": {
"legalLinks": [
{
"label": "Legal Notice",
"href": "#"
},
{
"label": "Personal data and cookies",
"href": "#"
},
{
"label": "Accessibility",
"href": "#"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"fr": {
"legalLinks": [
{
"label": "Mentions légales",
"href": "#"
},
{
"label": "Données personnelles et cookies",
"href": "#"
},
{
"label": "Accessibilité",
"href": "#"
}
],
"bottomInformation": {
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"de": {
"legalLinks": [
{
"label": "Impressum",
"href": "#"
},
{
"label": "Personenbezogene Daten und Cookies",
"href": "#"
},
{
"label": "Barrierefreiheit",
"href": "#"
}
],
"bottomInformation": {
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"nl": {
"legalLinks": [
{
"label": "Wettelijke bepalingen",
"href": "#"
},
{
"label": "Persoonlijke gegevens en cookies",
"href": "#"
},
{
"label": "Toegankelijkheid",
"href": "#"
}
],
"bottomInformation": {
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
}
}
}

View File

@@ -317,6 +317,7 @@ class Base(Configuration):
"django.contrib.staticfiles",
# OIDC third party
"mozilla_django_oidc",
"lasuite.malware_detection",
]
# Cache
@@ -422,23 +423,22 @@ class Base(Configuration):
environ_name="FRONTEND_HOMEPAGE_FEATURE_ENABLED",
environ_prefix=None,
)
FRONTEND_URL_JSON_FOOTER = values.Value(
None, environ_name="FRONTEND_URL_JSON_FOOTER", environ_prefix=None
)
FRONTEND_FOOTER_FEATURE_ENABLED = values.BooleanValue(
default=False,
environ_name="FRONTEND_FOOTER_FEATURE_ENABLED",
environ_prefix=None,
)
FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT = values.Value(
60 * 60 * 24,
environ_name="FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT",
environ_prefix=None,
)
FRONTEND_CSS_URL = values.Value(
None, environ_name="FRONTEND_CSS_URL", environ_prefix=None
)
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
environ_prefix=None,
)
THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.Value(
60 * 60 * 24,
environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT",
environ_prefix=None,
)
# Posthog
POSTHOG_KEY = values.DictValue(
None, environ_name="POSTHOG_KEY", environ_prefix=None
@@ -680,6 +680,21 @@ class Base(Configuration):
},
}
MALWARE_DETECTION = {
"BACKEND": values.Value(
"lasuite.malware_detection.backends.dummy.DummyBackend",
environ_name="MALWARE_DETECTION_BACKEND",
environ_prefix=None,
),
"PARAMETERS": values.DictValue(
default={
"callback_path": "core.malware_detection.malware_detection_callback",
},
environ_name="MALWARE_DETECTION_PARAMETERS",
environ_prefix=None,
),
}
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
default=5,
environ_name="API_USERS_LIST_LIMIT",
@@ -898,6 +913,11 @@ class Production(Base):
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
"KEY_PREFIX": values.Value(
"docs",
environ_name="CACHES_KEY_PREFIX",
environ_prefix=None,
),
},
}

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.2.0"
version = "3.2.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,21 +25,20 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.3",
"boto3==1.37.33",
"beautifulsoup4==4.13.4",
"boto3==1.38.13",
"Brotli==1.1.0",
"celery[redis]==5.5.1",
"celery[redis]==5.5.2",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-filter==25.1",
"django-lasuite==0.0.7",
"django-lasuite[all]==0.0.8",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.1.8",
"django==5.1.9",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
@@ -48,17 +47,18 @@ dependencies = [
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"lxml==5.3.2",
"lxml==5.4.0",
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.73.0",
"psycopg[binary]==3.2.6",
"pycrdt==0.12.12",
"openai==1.78.1",
"psycopg[binary]==3.2.8",
"pycrdt==0.12.15",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.3",
"sentry-sdk==2.25.1",
"sentry-sdk==2.28.0",
"whitenoise==6.9.0",
]
@@ -71,21 +71,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"django-test-migrations==1.4.0",
"drf-spectacular-sidecar==2025.4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.5.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==9.1.0",
"ipython==9.2.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.6",
"pylint==3.3.7",
"pytest-cov==6.1.1",
"pytest-django==4.11.1",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.5",
"ruff==0.11.9",
"types-requests==2.32.0.20250328",
]

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend-deps
FROM node:24-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -39,10 +39,13 @@ ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
ARG PUBLISH_AS_MIT
ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
FROM nginxinc/nginx-unprivileged:1.27-alpine AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER

View File

@@ -7,7 +7,6 @@ export const CONFIG = {
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
FRONTEND_FOOTER_FEATURE_ENABLED: true,
FRONTEND_THEME: 'default',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
@@ -20,6 +19,7 @@ export const CONFIG = {
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
SENTRY_DSN: null,
theme_customization: {},
};
export const keyCloakSignIn = async (

View File

@@ -452,4 +452,41 @@ test.describe('Doc Editor', () => {
const svgBuffer = await cs.toBuffer(await download.createReadStream());
expect(svgBuffer.toString()).toContain('Hello svg');
});
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Add a callout block').click();
const calloutBlock = page
.locator('div[data-content-type="callout"]')
.first();
await expect(calloutBlock).toBeVisible();
await calloutBlock.locator('.inline-content').fill('example text');
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'yellow',
);
const emojiButton = calloutBlock.getByRole('button');
await expect(emojiButton).toHaveText('💡');
await emojiButton.click();
await page.locator('button[aria-label="⚠️"]').click();
await expect(emojiButton).toHaveText('⚠️');
await page.locator('.bn-side-menu > button').last().click();
await page.locator('.mantine-Menu-dropdown > button').last().click();
await page.locator('.bn-color-picker-dropdown > button').last().click();
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'pink',
);
});
});

View File

@@ -141,7 +141,7 @@ test.describe('Doc Export', () => {
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are beeing displayed correctly
* but it does not tell us if the images are being displayed correctly
* in the pdf.
*
* TODO: Check if the images are displayed correctly in the pdf

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.2.0",
"version": "3.2.1",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",

View File

@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_SW_DEACTIVATED=
NEXT_PUBLIC_PUBLISH_AS_MIT=true

View File

@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_PUBLISH_AS_MIT=false
NEXT_PUBLIC_SW_DEACTIVATED=true

View File

@@ -48,10 +48,7 @@ const nextConfig = {
swDest: '../public/service-worker.js',
include: [
({ asset }) => {
if (asset.name.match(/.*(static).*/)) {
return true;
}
return false;
return !!asset.name.match(/.*(static).*/);
},
],
}),

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.2.0",
"version": "3.2.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -22,26 +22,29 @@
"@blocknote/react": "0.29.1",
"@blocknote/xl-docx-exporter": "0.29.1",
"@blocknote/xl-pdf-exporter": "0.29.1",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.4.1",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.0.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.14.0",
"@tanstack/react-query": "5.74.9",
"@sentry/nextjs": "9.15.0",
"@tanstack/react-query": "5.75.4",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.4.1",
"i18next": "25.0.2",
"i18next-browser-languagedetector": "8.0.5",
"emoji-mart": "5.6.0",
"i18next": "25.1.1",
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.2",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.1",
"posthog-js": "1.236.8",
"posthog-js": "1.239.1",
"react": "*",
"react-aria-components": "1.8.0",
"react-dom": "*",
@@ -52,11 +55,11 @@
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.3"
"zustand": "5.0.4"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.74.9",
"@tanstack/react-query-devtools": "5.75.4",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",

View File

@@ -1,121 +0,0 @@
{
"default": {
"externalLinks": [
{
"label": "Github",
"href": "https://github.com/suitenumerique/docs/"
},
{
"label": "DINUM",
"href": "https://www.numerique.gouv.fr/dinum/"
},
{
"label": "ZenDiS",
"href": "https://zendis.de/"
},
{
"label": "BlockNote.js",
"href": "https://www.blocknotejs.org/"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"en": {
"legalLinks": [
{
"label": "Legal Notice",
"href": "#"
},
{
"label": "Personal data and cookies",
"href": "#"
},
{
"label": "Accessibility",
"href": "#"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"fr": {
"legalLinks": [
{
"label": "Mentions légales",
"href": "#"
},
{
"label": "Données personnelles et cookies",
"href": "#"
},
{
"label": "Accessibilité",
"href": "#"
}
],
"bottomInformation": {
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"de": {
"legalLinks": [
{
"label": "Impressum",
"href": "#"
},
{
"label": "Personenbezogene Daten und Cookies",
"href": "#"
},
{
"label": "Barrierefreiheit",
"href": "#"
}
],
"bottomInformation": {
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"nl": {
"legalLinks": [
{
"label": "Wettelijke bepalingen",
"href": "#"
},
{
"label": "Persoonlijke gegevens en cookies",
"href": "#"
},
{
"label": "Toegankelijkheid",
"href": "#"
}
],
"bottomInformation": {
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
}
}

View File

@@ -1,135 +0,0 @@
{
"default": {
"externalLinks": [
{
"label": "legifrance.gouv.fr",
"href": "https://legifrance.gouv.fr/"
},
{
"label": "info.gouv.fr",
"href": "https://info.gouv.fr/"
},
{
"label": "service-public.fr",
"href": "https://service-public.fr/"
},
{
"label": "data.gouv.fr",
"href": "https://data.gouv.fr/"
}
],
"legalLinks": [
{
"label": "Legal Notice",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Personal data and cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Accessibility",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"en": {
"legalLinks": [
{
"label": "Legal Notice",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Personal data and cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Accessibility",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"fr": {
"legalLinks": [
{
"label": "Mentions légales",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Données personnelles et cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Accessibilité",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"de": {
"legalLinks": [
{
"label": "Impressum",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Personenbezogene Daten und Cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Barrierefreiheit",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"nl": {
"legalLinks": [
{
"label": "Wettelijke bepalingen",
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
},
{
"label": "Persoonlijke gegevens en cookies",
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
},
{
"label": "Toegankelijkheid",
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
}
],
"bottomInformation": {
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
}
}

View File

@@ -1,17 +1,39 @@
/**
* Generic interface for representing an API error structure.
*
* @template T - Optional type of additional data returned with the error.
*/
interface IAPIError<T = unknown> {
/** HTTP status code or API-defined error code */
status: number;
/** Optional list of error causes (e.g., validation issues) */
cause?: string[];
/** Optional extra data provided with the error */
data?: T;
}
/**
* Custom error class for representing API errors.
* Extends the native Error object with additional context such as HTTP status,
* causes, and extra data returned by the API.
*
* @template T - Optional type of the `data` field
*/
export class APIError<T = unknown> extends Error implements IAPIError<T> {
public status: IAPIError['status'];
public cause?: IAPIError['cause'];
public data?: IAPIError<T>['data'];
/**
* Constructs a new APIError instance.
*
* @param message - The human-readable error message.
* @param status - The HTTP status code or equivalent.
* @param cause - (Optional) List of strings describing error causes.
* @param data - (Optional) Any additional data returned by the API.
*/
constructor(message: string, { status, cause, data }: IAPIError<T>) {
super(message);
this.name = 'APIError';
this.status = status;
this.cause = cause;
@@ -19,6 +41,12 @@ export class APIError<T = unknown> extends Error implements IAPIError<T> {
}
}
/**
* Type guard for checking if a value is an instance of APIError.
*
* @param error - The value to check.
* @returns True if the value is an instance of APIError.
*/
export const isAPIError = (error: unknown): error is APIError => {
return error instanceof APIError;
};

View File

@@ -0,0 +1,36 @@
import { APIError, isAPIError } from '@/api';
describe('APIError', () => {
it('should correctly instantiate with required fields', () => {
const error = new APIError('Something went wrong', { status: 500 });
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(APIError);
expect(error.message).toBe('Something went wrong');
expect(error.status).toBe(500);
expect(error.cause).toBeUndefined();
expect(error.data).toBeUndefined();
});
it('should correctly instantiate with all fields', () => {
const details = { field: 'email' };
const error = new APIError('Validation failed', {
status: 400,
cause: ['Invalid email format'],
data: details,
});
expect(error.name).toBe('APIError');
expect(error.status).toBe(400);
expect(error.cause).toEqual(['Invalid email format']);
expect(error.data).toEqual(details);
});
it('should be detected by isAPIError type guard', () => {
const error = new APIError('Unauthorized', { status: 401 });
const notAnError = { message: 'Fake error' };
expect(isAPIError(error)).toBe(true);
expect(isAPIError(notAnError)).toBe(false);
});
});

View File

@@ -0,0 +1,16 @@
import { baseApiUrl } from '@/api';
describe('config', () => {
it('constructs URL with default version', () => {
expect(baseApiUrl()).toBe('http://test.jest/api/v1.0/');
});
it('constructs URL with custom version', () => {
expect(baseApiUrl('2.0')).toBe('http://test.jest/api/v2.0/');
});
it('uses env origin if available', () => {
process.env.NEXT_PUBLIC_API_ORIGIN = 'https://env.example.com';
expect(baseApiUrl('3.0')).toBe('https://env.example.com/api/v3.0/');
});
});

View File

@@ -36,4 +36,13 @@ describe('fetchAPI', () => {
expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/v2.0/some/url');
});
it('removes Content-Type header when withoutContentType is true', async () => {
fetchMock.mock('http://test.jest/api/v1.0/some/url', 200);
await fetchAPI('some/url', { withoutContentType: true });
const options = fetchMock.lastOptions();
expect(options?.headers).not.toHaveProperty('Content-Type');
});
});

View File

@@ -0,0 +1,59 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { useAPIInfiniteQuery } from '@/api';
interface DummyItem {
id: number;
}
interface DummyResponse {
results: DummyItem[];
next?: string;
}
const createWrapper = () => {
const queryClient = new QueryClient();
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('helpers', () => {
it('fetches and paginates correctly', async () => {
const mockAPI = jest
.fn<Promise<DummyResponse>, [{ page: number; query: string }]>()
.mockResolvedValueOnce({
results: [{ id: 1 }],
next: 'url?page=2',
})
.mockResolvedValueOnce({
results: [{ id: 2 }],
next: undefined,
});
const { result } = renderHook(
() => useAPIInfiniteQuery('test-key', mockAPI, { query: 'test' }),
{ wrapper: createWrapper() },
);
// Wait for first page
await waitFor(() => {
expect(result.current.data?.pages[0].results[0].id).toBe(1);
});
// Fetch next page
await result.current.fetchNextPage();
await waitFor(() => {
expect(result.current.data?.pages.length).toBe(2);
});
await waitFor(() => {
expect(result.current.data?.pages[1].results[0].id).toBe(2);
});
expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 1 });
expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 2 });
});
});

View File

@@ -0,0 +1,57 @@
import { errorCauses, getCSRFToken } from '@/api';
describe('utils', () => {
describe('errorCauses', () => {
const createMockResponse = (jsonData: any, status = 400): Response => {
return {
status,
json: () => jsonData,
} as unknown as Response;
};
it('parses multiple string causes from error body', async () => {
const mockResponse = createMockResponse(
{
field: ['error message 1', 'error message 2'],
},
400,
);
const result = await errorCauses(mockResponse, { context: 'login' });
expect(result.status).toBe(400);
expect(result.cause).toEqual(['error message 1', 'error message 2']);
expect(result.data).toEqual({ context: 'login' });
});
it('returns undefined causes if no JSON body', async () => {
const mockResponse = createMockResponse(null, 500);
const result = await errorCauses(mockResponse);
expect(result.status).toBe(500);
expect(result.cause).toBeUndefined();
expect(result.data).toBeUndefined();
});
});
describe('getCSRFToken', () => {
it('extracts csrftoken from document.cookie', () => {
Object.defineProperty(document, 'cookie', {
writable: true,
value: 'sessionid=xyz; csrftoken=abc123; theme=dark',
});
expect(getCSRFToken()).toBe('abc123');
});
it('returns undefined if csrftoken is not present', () => {
Object.defineProperty(document, 'cookie', {
writable: true,
value: 'sessionid=xyz; theme=dark',
});
expect(getCSRFToken()).toBeUndefined();
});
});
});

View File

@@ -1,6 +1,22 @@
/**
* Returns the base URL for the backend API.
*
* Priority:
* 1. Uses NEXT_PUBLIC_API_ORIGIN from environment variables if defined.
* 2. Falls back to the browser's window.location.origin if in a browser environment.
* 3. Defaults to an empty string if executed in a non-browser environment without the env variable.
*
* @returns The backend base URL as a string.
*/
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
/**
* Constructs the full base API URL, including the versioned path (e.g., `/api/v1.0/`).
*
* @param apiVersion - The version of the API (defaults to '1.0').
* @returns The full versioned API base URL as a string.
*/
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;

View File

@@ -23,11 +23,9 @@ export const fetchAPI = async (
delete headers?.['Content-Type' as keyof typeof headers];
}
const response = await fetch(apiUrl, {
return await fetch(apiUrl, {
...init,
credentials: 'include',
headers,
});
return response;
};

View File

@@ -22,9 +22,17 @@ export type DefinedInitialDataInfiniteOptionsAPI<
>;
/**
* @param param Used for infinite scroll pagination
* @param queryConfig
* @returns
* Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests.
*
* @template T - Type of the request parameters.
* @template Q - Type of the API response, which must include an optional `next` field for pagination.
*
* @param {string} key - Unique key to identify the query in the cache.
* @param {(props: T & { page: number }) => Promise<Q>} api - Function that fetches paginated data from the API. It receives the params merged with a page number.
* @param {T} param - Static parameters to send with every API request (excluding the page number).
* @param {DefinedInitialDataInfiniteOptionsAPI<Q>} [queryConfig] - Optional configuration passed to `useInfiniteQuery` (e.g., stale time, cache time).
*
* @returns Return value of `useInfiniteQuery`, including data, loading state, fetchNextPage, etc.
*/
export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
key: string,

View File

@@ -1,6 +1,20 @@
/**
* Generic interface representing a paginated API response.
*
* Commonly used for endpoints that return list results with pagination metadata.
*
* @template T - The type of items in the `results` array.
*/
export interface APIList<T> {
/** Total number of items across all pages */
count: number;
/** URL to the next page of results, if available (can be null or undefined) */
next?: string | null;
/** URL to the previous page of results, if available (can be null or undefined) */
previous?: string | null;
/** The list of items for the current page */
results: T[];
}

View File

@@ -1,3 +1,16 @@
/**
* Extracts error information from an HTTP `Response` object.
*
* This is typically used to parse structured error responses from an API
* and normalize them into a consistent format with `status`, `cause`, and optional `data`.
*
* @param response - The HTTP response object from `fetch()`.
* @param data - Optional custom data to include with the error output.
* @returns An object containing:
* - `status`: HTTP status code from the response
* - `cause`: A flattened list of error messages, or undefined if no body
* - `data`: The optional data passed in
*/
export const errorCauses = async (response: Response, data?: unknown) => {
const errorsBody = (await response.json()) as Record<
string,
@@ -18,7 +31,11 @@ export const errorCauses = async (response: Response, data?: unknown) => {
};
/**
* Retrieves the CSRF token from the document's cookies.
* Retrieves the CSRF token from the browser's cookies.
*
* Assumes the CSRF token is stored as a cookie named "csrftoken".
*
* @returns The CSRF token string if found, otherwise `undefined`.
*/
export function getCSRFToken() {
return document.cookie

View File

@@ -10,7 +10,7 @@ type TextSizes = keyof typeof sizes;
export interface TextProps extends BoxProps {
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
$elipsis?: boolean;
$ellipsis?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$size?: TextSizes | (string & {});
@@ -50,8 +50,8 @@ export const TextStyled = styled(Box)<TextProps>`
${({ $theme, $variation }) =>
`color: var(--c--theme--colors--${$theme}-${$variation});`}
${({ $color }) => $color && `color: ${$color};`}
${({ $elipsis }) =>
$elipsis &&
${({ $ellipsis }) =>
$ellipsis &&
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;

View File

@@ -35,8 +35,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
useEffect(() => {
const cleanupResizeListener = initializeResizeListener();
return cleanupResizeListener;
return initializeResizeListener();
}, [initializeResizeListener]);
useEffect(() => {

View File

@@ -53,13 +53,11 @@ export function useConfig() {
const cachedData = getCachedTranslation();
const oneHour = 1000 * 60 * 60;
const response = useQuery<ConfigResponse, APIError, ConfigResponse>({
return useQuery<ConfigResponse, APIError, ConfigResponse>({
queryKey: [KEY_CONFIG],
queryFn: () => getConfig(),
initialData: cachedData,
staleTime: oneHour,
initialDataUpdatedAt: Date.now() - oneHour, // Force initial data to be considered stale
});
return response;
}

View File

@@ -20,6 +20,7 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_PUBLISH_AS_MIT?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
}
}

View File

@@ -27,12 +27,13 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { DividerBlock } from './custom-blocks';
import { CalloutBlock, DividerBlock } from './custom-blocks';
export const blockNoteSchema = withPageBreak(
BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
},
}),

View File

@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next';
import { DocsBlockSchema } from '../types';
import { getDividerReactSlashMenuItems } from './custom-blocks';
import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
} from './custom-blocks';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor<DocsBlockSchema>();
@@ -25,6 +28,7 @@ export const BlockNoteSuggestionMenu = () => {
combineByGroup(
getDefaultReactSlashMenuItems(editor),
getPageBreakReactSlashMenuItems(editor),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
),
query,

View File

@@ -6,9 +6,12 @@ import {
useDictionary,
} from '@blocknote/react';
import React, { JSX, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core/config/api';
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
import { AIGroupButton } from './AIButton';
import { FileDownloadButton } from './FileDownloadButton';
import { MarkdownButton } from './MarkdownButton';
@@ -18,11 +21,13 @@ export const BlockNoteToolbar = () => {
const dict = useDictionary();
const [confirmOpen, setIsConfirmOpen] = useState(false);
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
const { t } = useTranslation();
const { data: conf } = useConfig();
const toolbarItems = useMemo(() => {
const toolbarItems = getFormattingToolbarItems([
...blockTypeSelectItems(dict),
getCalloutFormattingToolbarItems(t),
]);
const fileDownloadButtonIndex = toolbarItems.findIndex(
(item) =>
@@ -46,7 +51,7 @@ export const BlockNoteToolbar = () => {
}
return toolbarItems as JSX.Element[];
}, [dict]);
}, [dict, t]);
const formattingToolbar = useCallback(() => {
return (

View File

@@ -0,0 +1,43 @@
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
interface EmojiPickerProps {
categories: string[];
custom: {
name: string;
id: string;
emojis: string[];
}[];
onClickOutside: () => void;
onEmojiSelect: ({ native }: { native: string }) => void;
}
export const EmojiPicker = ({
categories,
custom,
onClickOutside,
onEmojiSelect,
}: EmojiPickerProps) => {
const { i18n } = useTranslation();
return (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Picker
categories={categories}
custom={custom}
data={data}
locale={i18n.resolvedLanguage}
navPosition="none"
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
previewPosition="none"
skinTonePosition="none"
theme="light"
/>
</Box>
);
};

View File

@@ -0,0 +1,166 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
const calloutCustom = [
{
name: 'Callout',
id: 'callout',
emojis: [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
],
},
];
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
export const CalloutBlock = createReactBlockSpec(
{
type: 'callout',
propSchema: {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
emoji: { default: '💡' },
},
content: 'inline',
},
{
render: ({ block, editor, contentRef }) => {
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
const toggleEmojiPicker = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpenEmojiPicker(!openEmojiPicker);
};
const onClickOutside = () => setOpenEmojiPicker(false);
const onEmojiSelect = ({ native }: { native: string }) => {
editor.updateBlock(block, { props: { emoji: native } });
setOpenEmojiPicker(false);
};
// Temporary: sets a yellow background color to a callout block when added by
// the user, while keeping the colors menu on the drag handler usable for
// this custom block.
useEffect(() => {
if (
!block.content.length &&
block.props.backgroundColor === 'default'
) {
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
}
}, [block, editor]);
return (
<Box
$padding="1rem"
$gap="0.625rem"
style={{
flexGrow: 1,
flexDirection: 'row',
}}
>
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
$align="center"
$height="28px"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
{openEmojiPicker && (
<EmojiPicker
categories={calloutCategories}
custom={calloutCustom}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
)}
<Box as="p" className="inline-content" ref={contentRef} />
</Box>
);
},
},
);
export const getCalloutReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Callout'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'callout',
});
},
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
group,
icon: <Icon iconName="lightbulb" $size="18px" />,
subtext: t('Add a callout block'),
},
];
export const getCalloutFormattingToolbarItems = (
t: TFunction<'translation', undefined>,
): BlockTypeSelectItem => ({
name: t('Callout'),
type: 'callout',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'callout',
});

View File

@@ -1 +1,2 @@
export * from './CalloutBlock';
export * from './DividerBlock';

View File

@@ -31,7 +31,7 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
_updatedDoc: Y.Doc,
transaction: Y.Transaction,
) => {
setIsLocalChange(transaction.local ? true : false);
setIsLocalChange(transaction.local);
};
yDoc.on('update', onUpdate);
@@ -61,7 +61,7 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
const isSaving = saveDoc();
/**
* Firefox does not trigger the request everytime the user leaves the page.
* Firefox does not trigger the request every time the user leaves the page.
* Plus the request is not intercepted by the service worker.
* So we prevent the default behavior to have the popup asking the user
* if he wants to leave the page, by adding the popup, we let the time to the

View File

@@ -61,6 +61,23 @@ export const cssEditor = (readonly: boolean) => css`
height: 38px;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='paragraph'],
.bn-block-content[data-content-type='heading'] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
h1 {
font-size: 1.875rem;
}

View File

@@ -0,0 +1,24 @@
import { Paragraph, TextRun } from 'docx';
import { DocsExporterDocx } from '../types';
import { docxBlockPropsToStyles } from '../utils';
export const blockMappingCalloutDocx: DocsExporterDocx['mappings']['blockMapping']['callout'] =
(block, exporter) => {
return new Paragraph({
...docxBlockPropsToStyles(block.props, exporter.options.colors),
spacing: { before: 10, after: 10 },
children: [
new TextRun({
text: ' ',
break: 1,
}),
new TextRun(' ' + block.props.emoji + ' '),
...exporter.transformInlineContent(block.content),
new TextRun({
text: ' ',
break: 1,
}),
],
});
};

View File

@@ -0,0 +1,35 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
const styles = StyleSheet.create({
wrapper: {
width: '100%',
display: 'flex',
flexDirection: 'row',
padding: 8,
gap: 4,
},
emoji: {
fontSize: 16,
},
text: {
maxWidth: '94%',
paddingTop: 2,
},
});
export const blockMappingCalloutPDF: DocsExporterPDF['mappings']['blockMapping']['callout'] =
(block, exporter) => {
return (
<View wrap={false} style={styles.wrapper}>
<Text debug={false} style={styles.emoji}>
{block.props.emoji}
</Text>
<Text debug={false} style={styles.text}>
{' '}
{exporter.transformInlineContent(block.content)}{' '}
</Text>
</View>
);
};

View File

@@ -1,3 +1,5 @@
export * from './calloutDocx';
export * from './calloutPDF';
export * from './dividerDocx';
export * from './dividerPDF';
export * from './headingPDF';

View File

@@ -4,7 +4,7 @@
* See:
* https://github.com/TypeCellOS/BlockNote/blob/004c0bf720fe1415c497ad56449015c5f4dd7ba0/packages/xl-pdf-exporter/src/pdf/util/table/Table.tsx
*
* We succeded to manage the colspan, but rowspan is not supported yet.
* We succeeded to manage the colspan, but rowspan is not supported yet.
*/
import { TD, TR, Table } from '@ag-media/react-pdf-table';

View File

@@ -1,6 +1,7 @@
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
import {
blockMappingCalloutDocx,
blockMappingDividerDocx,
blockMappingImageDocx,
blockMappingQuoteDocx,
@@ -11,6 +12,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
...docxDefaultSchemaMappings,
blockMapping: {
...docxDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutDocx,
divider: blockMappingDividerDocx,
quote: blockMappingQuoteDocx,
image: blockMappingImageDocx,

View File

@@ -1,6 +1,7 @@
import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
import {
blockMappingCalloutPDF,
blockMappingDividerPDF,
blockMappingHeadingPDF,
blockMappingImagePDF,
@@ -14,6 +15,7 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
...pdfDefaultSchemaMappings,
blockMapping: {
...pdfDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutPDF,
heading: blockMappingHeadingPDF,
image: blockMappingImagePDF,
paragraph: blockMappingParagraphPDF,

View File

@@ -19,9 +19,16 @@ export function downloadFile(blob: Blob, filename: string) {
}
/**
* Convert SVG to PNG
* @param svgText - The SVG text to convert
* @returns The PNG data URL
* Converts an SVG string into a PNG image and returns it as a data URL.
*
* This function creates a canvas, parses the SVG, calculates the appropriate height
* to preserve the aspect ratio, and renders the SVG onto the canvas using Canvg.
*
* @param {string} svgText - The raw SVG markup to convert.
* @param {number} width - The desired width of the output PNG (height is auto-calculated to preserve aspect ratio).
* @returns {Promise<string>} A Promise that resolves to a PNG image encoded as a base64 data URL.
*
* @throws Will throw an error if the canvas context cannot be initialized.
*/
export async function convertSvgToPng(svgText: string, width: number) {
// Create a canvas and render the SVG onto it

View File

@@ -42,6 +42,7 @@ const doc = {
beforeEach(() => {
Analytics.clearAnalytics();
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
});
describe('DocToolBox "Copy as HTML" option', () => {
@@ -51,7 +52,9 @@ describe('DocToolBox "Copy as HTML" option', () => {
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = screen.getByLabelText('Open the document options');
const optionsButton = await screen.findByLabelText(
'Open the document options',
);
await userEvent.click(optionsButton);
expect(await screen.findByText('Copy as HTML')).toBeInTheDocument();
});

View File

@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { DocToolBox } from '../components/DocToolBox';
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
jest.mock('@/features/docs/doc-export/', () => ({
ModalExport: () => <span>ModalExport</span>,
}));
it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
expect(await screen.findByText('download')).toBeInTheDocument();
});

View File

@@ -0,0 +1,35 @@
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
jest.mock('@/features/docs/doc-export/', () => ({
ModalExport: () => <span>ModalExport</span>,
}));
it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
const { DocToolBox } = await import('../components/DocToolBox');
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
await waitFor(
() => {
expect(screen.queryByText('download')).not.toBeInTheDocument();
},
{
timeout: 1000,
},
);
});

View File

@@ -1,170 +1,47 @@
import {
Button,
VariantType,
useModal,
useToastProvider,
} from '@openfun/cunningham-react';
import { Button, useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/docs/doc-editor/';
import { ModalExport } from '@/docs/doc-export/';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
} from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { Doc } from '@/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
interface DocToolBoxProps {
doc: Doc;
}
const DocToolBoxLicence = dynamic(() =>
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false'
? import('./DocToolBoxLicenceAGPL').then((mod) => mod.DocToolBoxLicenceAGPL)
: import('./DocToolBoxLicenceMIT').then((mod) => mod.DocToolBoxLicenceMIT),
);
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const selectHistoryModal = useModal();
const modalHistory = useModal();
const modalShare = useModal();
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: 'push_pin',
callback: () => {
if (doc.is_favorite) {
removeFavoriteDoc.mutate({ id: doc.id });
} else {
makeFavoriteDoc.mutate({ id: doc.id });
}
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
selectHistoryModal.open();
},
show: isDesktop,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
show: isFeatureFlagActivated('CopyAsHTML'),
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown',
) => {
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
return;
}
try {
const editorContentFormatted =
asFormat === 'html'
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
} catch (error) {
console.error(error);
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
duration: 3000,
});
}
};
const { isSmallMobile } = useResponsiveStore();
useEffect(() => {
if (selectHistoryModal.isOpen) {
if (modalHistory.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [selectHistoryModal.isOpen, queryClient]);
}, [modalHistory.isOpen, queryClient]);
return (
<Box
@@ -222,55 +99,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
</>
)}
{!isSmallMobile && (
<Button
color="tertiary-text"
icon={
<Icon iconName="download" $theme="primary" $variation="800" />
}
onClick={() => {
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
)}
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
</Box>
{modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
)}
{isModalExportOpen && (
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
)}
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
{selectHistoryModal.isOpen && (
<ModalSelectVersion
onClose={() => selectHistoryModal.close()}
<DocToolBoxLicence
doc={doc}
modalHistory={modalHistory}
modalShare={modalShare}
/>
)}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,192 @@
import { Button, useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { ModalExport } from '@/docs/doc-export/';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
} from '@/docs/doc-management';
import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { DocShareModal } from '../../doc-share';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
type ModalType = ReturnType<typeof useModal>;
interface DocToolBoxLicenceProps {
doc: Doc;
modalHistory: ModalType;
modalShare: ModalType;
}
export const DocToolBoxLicenceAGPL = ({
doc,
modalHistory,
modalShare,
}: DocToolBoxLicenceProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { colorsTokens } = useCunninghamTheme();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const { isSmallMobile, isDesktop } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: 'push_pin',
callback: () => {
if (doc.is_favorite) {
removeFavoriteDoc.mutate({ id: doc.id });
} else {
makeFavoriteDoc.mutate({ id: doc.id });
}
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
modalHistory.open();
},
show: isDesktop,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
show: isFeatureFlagActivated('CopyAsHTML'),
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
useEffect(() => {
if (modalHistory.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [modalHistory.isOpen, queryClient]);
return (
<>
{!isSmallMobile && (
<Button
color="tertiary-text"
icon={<Icon iconName="download" $theme="primary" $variation="800" />}
onClick={() => {
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
)}
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
{modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
)}
{isModalExportOpen && (
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
)}
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
{modalHistory.isOpen && (
<ModalSelectVersion onClose={() => modalHistory.close()} doc={doc} />
)}
</>
);
};

View File

@@ -0,0 +1,165 @@
import { useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, IconOptions } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
} from '@/docs/doc-management';
import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { DocShareModal } from '../../doc-share';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
type ModalType = ReturnType<typeof useModal>;
interface DocToolBoxLicenceProps {
doc: Doc;
modalHistory: ModalType;
modalShare: ModalType;
}
export const DocToolBoxLicenceMIT = ({
doc,
modalHistory,
modalShare,
}: DocToolBoxLicenceProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { colorsTokens } = useCunninghamTheme();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const { isSmallMobile, isDesktop } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: 'push_pin',
callback: () => {
if (doc.is_favorite) {
removeFavoriteDoc.mutate({ id: doc.id });
} else {
makeFavoriteDoc.mutate({ id: doc.id });
}
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
modalHistory.open();
},
show: isDesktop,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
show: isFeatureFlagActivated('CopyAsHTML'),
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
useEffect(() => {
if (modalHistory.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [modalHistory.isOpen, queryClient]);
return (
<>
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
{modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
)}
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
{modalHistory.isOpen && (
<ModalSelectVersion onClose={() => modalHistory.close()} doc={doc} />
)}
</>
);
};

View File

@@ -0,0 +1,31 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../doc-editor';
export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const { t } = useTranslation();
return async (asFormat: 'html' | 'markdown') => {
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
return;
}
try {
const editorContentFormatted =
asFormat === 'html'
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
} catch (error) {
console.error(error);
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
duration: 3000,
});
}
};
};

View File

@@ -38,11 +38,15 @@ setCacheNameDetails({
});
/**
* In development, use NetworkFirst strategy, in production use CacheFirst strategy
* We will be able to test the application in development without having to clear the cache.
* @param url
* @param options
* @returns strategy
* Chooses the appropriate caching strategy based on the environment and request context.
*
* - In **development**, or for **API requests**, or **HTML pages**, it returns a `NetworkFirst` strategy
* to prioritize fresh responses and ease debugging without needing to clear caches.
* - In **production** (for non-API, non-HTML content), it returns a `CacheFirst` strategy
* to favor performance and offline access.
*
* @param {NetworkFirstOptions | StrategyOptions} [options] - Configuration options for the caching strategy.
* @returns {NetworkFirst | CacheFirst} The selected Workbox strategy instance.
*/
const getStrategy = (
options?: NetworkFirstOptions | StrategyOptions,
@@ -181,7 +185,7 @@ registerRoute(
);
/**
* Cache stategy static files images (images / svg)
* Cache strategy static files images (images / svg)
*/
registerRoute(
({ request }) => request.destination === 'image',
@@ -197,7 +201,7 @@ registerRoute(
);
/**
* Cache stategy static files fonts
* Cache strategy static files fonts
*/
googleFontsCache();
registerRoute(
@@ -214,7 +218,7 @@ registerRoute(
);
/**
* Cache stategy static files (css, js, workers)
* Cache strategy static files (css, js, workers)
*/
registerRoute(
({ request }) =>

View File

@@ -53,6 +53,7 @@
"Accessible to anyone": "Für alle zugänglich",
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
"Add": "Hinzufügen",
"Add a callout block": "Hebt schrift hervor",
"Add a horizontal line": "Waagerechte Linie einfügen",
"Address:": "Anschrift:",
"Administrator": "Administrator",
@@ -67,6 +68,7 @@
"Available soon": "Bald verfügbar",
"Banner image": "Bannerbild",
"Beautify": "Verschönern",
"Callout": "Hervorhebung",
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
"Cancel": "Abbrechen",
"Close the modal": "Pop up schliessen",
@@ -284,6 +286,7 @@
"Accessible to anyone": "Accesible para todos",
"Accessible to authenticated users": "Accesible a usuarios autenticados",
"Add": "Añadir",
"Add a callout block": "Resaltar el texto para que destaque",
"Add a horizontal line": "Añadir una línea horizontal",
"Address:": "Dirección:",
"Administrator": "Administrador",
@@ -298,6 +301,7 @@
"Available soon": "Próximamente disponible",
"Banner image": "Imagen de portada",
"Beautify": "Embellecer",
"Callout": "Destacado",
"Can't load this page, please check your internet connection.": "No se puede cargar esta página, por favor compruebe su conexión a Internet.",
"Cancel": "Cancelar",
"Close the modal": "Cerrar modal",
@@ -508,6 +512,7 @@
"Accessible to authenticated users": "Accessible aux utilisateurs authentifiés",
"Add": "Ajouter",
"Add a horizontal line": "Ajouter une ligne horizontale",
"Add a callout block": "Faites ressortir du texte pour le mettre en évidence",
"Address:": "Adresse :",
"Administrator": "Administrateur",
"All docs": "Tous les documents",
@@ -521,6 +526,7 @@
"Available soon": "Disponible prochainement",
"Banner image": "Image de la bannière",
"Beautify": "Embellir",
"Callout": "Encadré",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Close the modal": "Fermer la modale",
@@ -914,6 +920,7 @@
"Accessible to anyone": "Toegankelijk voor iedereen",
"Accessible to authenticated users": "Toegankelijk voor geauthentiseerde gebruikers",
"Add": "Voeg toe",
"Add a callout block": "Laat je tekst opvallen",
"Add a horizontal line": "Voeg horizontale lijn toe",
"Address:": "Adres:",
"Administrator": "Administrator",
@@ -928,6 +935,7 @@
"Available soon": "Binnenkort beschikbaar",
"Banner image": "Banner afbeelding",
"Beautify": "Maak mooier",
"Callout": "Benadrukken",
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
"Cancel": "Breek af",
"Close the modal": "Sluit het venster",

View File

@@ -25,14 +25,10 @@ export class PostHogAnalytic extends AbstractAnalytic {
}
public isFeatureFlagActivated(flagName: string): boolean {
if (
return !(
posthog.featureFlags.getFlags().includes(flagName) &&
posthog.isFeatureEnabled(flagName) === false
) {
return false;
}
return true;
);
}
}

View File

@@ -40,11 +40,7 @@ export function isSafeUrl(url: string): boolean {
}
// Check for directory traversal attempts
if (url.includes('..') || url.includes('../') || url.includes('..\\')) {
return false;
}
return true;
return !(url.includes('..') || url.includes('../') || url.includes('..\\'));
} catch {
return false;
}

View File

@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "3.2.0",
"version": "3.2.1",
"private": true,
"workspaces": {
"packages": [
@@ -28,12 +28,13 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@types/node": "22.15.3",
"@types/react": "19.1.2",
"@types/react-dom": "19.1.2",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@types/node": "22.15.12",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"eslint": "8.57.0",
"prosemirror-model": "1.25.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "5.8.3",

View File

@@ -56,6 +56,7 @@ const globalRules = {
'error',
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
],
'no-var': 'error',
'react/jsx-curly-brace-presence': [
'error',
{ props: 'never', children: 'never', propElementValues: 'always' },

View File

@@ -1,6 +1,6 @@
{
"name": "eslint-config-impress",
"version": "3.2.0",
"version": "3.2.1",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
@@ -17,7 +17,7 @@
"eslint-plugin-jest": "28.11.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-prettier": "5.2.6",
"eslint-plugin-prettier": "5.4.0",
"eslint-plugin-testing-library": "7.1.1",
"prettier": "3.5.3"
}

View File

@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "3.2.0",
"version": "3.2.1",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",

View File

@@ -13,5 +13,5 @@ module.exports = {
rules: {
'@next/next/no-html-link-for-pages': 'off',
},
ignorePatterns: ['node_modules', '.eslintrc.js'],
ignorePatterns: ['node_modules'],
};

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS y-provider-builder
FROM node:24-alpine AS y-provider-builder
WORKDIR /home/frontend/
@@ -15,7 +15,7 @@ COPY ./src/frontend/servers/y-provider ./servers/y-provider
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
FROM node:20-alpine AS y-provider
FROM node:24-alpine AS y-provider
WORKDIR /home/frontend/

View File

@@ -1,4 +1,4 @@
var config = {
const config = {
rootDir: './__tests__',
testEnvironment: 'node',
transform: {

View File

@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "3.2.0",
"version": "3.2.1",
"description": "Y.js provider for docs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",
@@ -18,8 +18,8 @@
"dependencies": {
"@blocknote/server-util": "0.29.1",
"@hocuspocus/server": "2.15.2",
"@sentry/node": "9.14.0",
"@sentry/profiling-node": "9.14.0",
"@sentry/node": "9.15.0",
"@sentry/profiling-node": "9.15.0",
"axios": "1.9.0",
"cors": "2.8.5",
"express": "5.1.0",
@@ -44,8 +44,8 @@
"supertest": "7.1.0",
"ts-jest": "29.3.2",
"ts-node": "10.9.2",
"tsc-alias": "1.8.15",
"tsc-alias": "1.8.16",
"typescript": "*",
"ws": "8.18.1"
"ws": "8.18.2"
}
}

View File

@@ -17,8 +17,6 @@ import { logger } from '../utils';
/**
* init the collaboration server.
*
* @param port - The port on which the server listens.
* @param serverSecret - The secret key for API authentication.
* @returns An object containing the Express app, Hocuspocus server, and HTTP server instance.
*/
export const initServer = () => {

View File

@@ -1238,11 +1238,16 @@
dependencies:
tslib "^2.4.0"
"@emoji-mart/data@^1.2.1":
"@emoji-mart/data@1.2.1", "@emoji-mart/data@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
"@emoji-mart/react@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
integrity sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==
"@emotion/babel-plugin@^11.13.5":
version "11.13.5"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0"
@@ -1480,6 +1485,13 @@
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/eslint-utils@^4.7.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"
integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1":
version "4.12.1"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
@@ -4417,19 +4429,19 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz#75dce8e972f90bba488e2b0cc677fb233aa357ab"
integrity sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==
"@sentry-internal/browser-utils@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-9.14.0.tgz#d864b6c6c41fb409baa5df5080443f7c33bf7b86"
integrity sha512-pDk9XUu9zf7lcT9QX0nTObPNp/y0xQyy1Dj+5/8TSB3vAfe0LQcooKGl/D1h7EoIXVHUozZk5JC/dH+gz6BXRg==
"@sentry-internal/browser-utils@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-9.15.0.tgz#999f0205fd6dc380f35c4a1133c64454c06a0fc3"
integrity sha512-tIM+9zXCefkInRiNmBkXKgkamRjEOlAcf768cBKlMWVOatfNrSEB0UEV7qkAYqnQGWkbPkHFMbFJxWptydLODw==
dependencies:
"@sentry/core" "9.14.0"
"@sentry/core" "9.15.0"
"@sentry-internal/feedback@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-9.14.0.tgz#d003ad6cddee140f7e82dbf649cf47e40489f6f9"
integrity sha512-D+PiEUWbDT0vqmaTiOs6OzXwVRVFgf7BCkFs48qsN9sAPwUgT+5zh2oo/rU2r0NrmMcvJVtSY+ezwPMk8BgGsg==
"@sentry-internal/feedback@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-9.15.0.tgz#dd3194e93765521559645fea2f2f864e62156134"
integrity sha512-jyN0r57WL8V5ViwZpiNvbIhF9I89jxn6mtIQcyV85EjIXDyzJmeTgxc/FIU0kcDVv6zso3qnGRJUxGK+GvoYZg==
dependencies:
"@sentry/core" "9.14.0"
"@sentry/core" "9.15.0"
"@sentry-internal/node-cpu-profiler@^2.0.0":
version "2.1.0"
@@ -4439,37 +4451,37 @@
detect-libc "^2.0.3"
node-abi "^3.73.0"
"@sentry-internal/replay-canvas@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-9.14.0.tgz#30516c5555c6646401ea97540a459a064910b1c4"
integrity sha512-GhCSqc0oNzRiLhQsi9LCXgUmIwdHdvzVIsX4fihoFYWfgWSSj5YLqeEkb3CMM8htM6vheSFzIbPLlRS8fjCrPQ==
"@sentry-internal/replay-canvas@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-9.15.0.tgz#7ec2a8bb8f571b78506cdba208ee6ed00faa7f40"
integrity sha512-a1/oiXwcW5OmILjD7/R2UEsPQWXJBUr0u388uCKDUGeyXLxBBbIJGS5E8oLwVQLVxhVJrODgxvT19z9OVcbn7g==
dependencies:
"@sentry-internal/replay" "9.14.0"
"@sentry/core" "9.14.0"
"@sentry-internal/replay" "9.15.0"
"@sentry/core" "9.15.0"
"@sentry-internal/replay@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-9.14.0.tgz#491d12930175e814c097b9b6ba097f47b4f4770c"
integrity sha512-wgt397/PtpfVQ9t779a0L+hGH3JN9doXv3+9Wj98MLWwhymvJBjpjCFUBLScO5iP6imewTbRqQHbq7XS7I+x1A==
"@sentry-internal/replay@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-9.15.0.tgz#7c6522b6b9d410f0f4b0b963777d2894fc11967f"
integrity sha512-lv6ENRmfeBuod6Tr1WgLeF0+wIIXlHWNAGofsaNUvm8UKS7USicFsQWKOZPk4UyjTfrEClPp2vx+o7aUcZS6TQ==
dependencies:
"@sentry-internal/browser-utils" "9.14.0"
"@sentry/core" "9.14.0"
"@sentry-internal/browser-utils" "9.15.0"
"@sentry/core" "9.15.0"
"@sentry/babel-plugin-component-annotate@3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.3.1.tgz#baecd89396cbb4659565a4e8efe7f0a71b19262a"
integrity sha512-5GOxGT7lZN+I8A7Vp0rWY+726FDKEw8HnFiebe51rQrMbfGfCu2Aw9uSM0nT9OG6xhV6WvGccIcCszTPs4fUZQ==
"@sentry/browser@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-9.14.0.tgz#159ca710028891e1e9b160d491ddfb774cf1ed67"
integrity sha512-acxFbFEei3hzKr/IW3OmkzHlwohRaRBG0872nIhLYV2f/BgZmR6eV5zrUoELMmt2cgoLmDYyfp1734OoplfDbw==
"@sentry/browser@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-9.15.0.tgz#a690b2bd5fb7ca18789887424e4c0773fd8262c8"
integrity sha512-ppHESKFVQFpAb3rQI2ateDkmMytVcvAWsjZrZ3hF9iEnO3iTIIu32ib5nqQUL4KKXZQovYnDrSlDcdv3ZwX/8Q==
dependencies:
"@sentry-internal/browser-utils" "9.14.0"
"@sentry-internal/feedback" "9.14.0"
"@sentry-internal/replay" "9.14.0"
"@sentry-internal/replay-canvas" "9.14.0"
"@sentry/core" "9.14.0"
"@sentry-internal/browser-utils" "9.15.0"
"@sentry-internal/feedback" "9.15.0"
"@sentry-internal/replay" "9.15.0"
"@sentry-internal/replay-canvas" "9.15.0"
"@sentry/core" "9.15.0"
"@sentry/bundler-plugin-core@3.3.1":
version "3.3.1"
@@ -4539,35 +4551,35 @@
"@sentry/cli-win32-i686" "2.42.2"
"@sentry/cli-win32-x64" "2.42.2"
"@sentry/core@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-9.14.0.tgz#4d51bf0ff719a8dee821d31be4abf1afd73f6c6e"
integrity sha512-OLfucnP3LAL5bxVNWc2RVOHCX7fk9Er5bWPCS+O5cPjqNUUz0HQHhVh2Vhei5C0kYZZM4vy4BQit5T9LrlOaNA==
"@sentry/core@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-9.15.0.tgz#590f16a15596ce01db49d9d80b31cb18048ca9a4"
integrity sha512-lBmo3bzzaYUesdzc2H5K3fajfXyUNuj5koqyFoCAI8rnt9CBl7SUc/P07+E5eipF8mxgiU3QtkI7ALzRQN8pqQ==
"@sentry/nextjs@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-9.14.0.tgz#ed42410432c17632fea8ce0fcc036c29a7588d1c"
integrity sha512-uQnG7tPs1qX8OZ5lW3mpslAoN2+XiV2ZJ/3T+VtBatx9MFgEga8lMA/qgdkZP+Q139sroGlUe0tcUSDwwIzaVw==
"@sentry/nextjs@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-9.15.0.tgz#de54a6f47693512b606d20b8caa104ca5734b92d"
integrity sha512-lx/q1Uqe37MtNff8UIBL5G8SaHn48lDlZyQKrsTd+4txBwT2DsAnyR029n/ZQW5bc1/rLM/qebKLy76x+Xq0vA==
dependencies:
"@opentelemetry/api" "^1.9.0"
"@opentelemetry/semantic-conventions" "^1.30.0"
"@rollup/plugin-commonjs" "28.0.1"
"@sentry-internal/browser-utils" "9.14.0"
"@sentry/core" "9.14.0"
"@sentry/node" "9.14.0"
"@sentry/opentelemetry" "9.14.0"
"@sentry/react" "9.14.0"
"@sentry/vercel-edge" "9.14.0"
"@sentry-internal/browser-utils" "9.15.0"
"@sentry/core" "9.15.0"
"@sentry/node" "9.15.0"
"@sentry/opentelemetry" "9.15.0"
"@sentry/react" "9.15.0"
"@sentry/vercel-edge" "9.15.0"
"@sentry/webpack-plugin" "3.3.1"
chalk "3.0.0"
resolve "1.22.8"
rollup "4.35.0"
stacktrace-parser "^0.1.10"
"@sentry/node@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-9.14.0.tgz#683d9dacc0d864710fe292e52e839e5fdc39c94f"
integrity sha512-AWPc6O+zAdSgnsiKm6Nb1txyiKCOIBriJEONdXFSslgZgkm8EWAYRRHyS2Hkmnz0/5bEQ3jEffIw22qJuaHN+Q==
"@sentry/node@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-9.15.0.tgz#f43954ed8ccdc1cb7d5c315b7d481c5f46300885"
integrity sha512-K0LdJxIzYbbsbiT+1tKgNq6MUHuDs2DggBDcFEp3T+yXVJYN1AyalUli06Kmxq8yvou6hgLwWL4gjIcB1IQySA==
dependencies:
"@opentelemetry/api" "^1.9.0"
"@opentelemetry/context-async-hooks" "^1.30.1"
@@ -4600,42 +4612,42 @@
"@opentelemetry/sdk-trace-base" "^1.30.1"
"@opentelemetry/semantic-conventions" "^1.30.0"
"@prisma/instrumentation" "6.6.0"
"@sentry/core" "9.14.0"
"@sentry/opentelemetry" "9.14.0"
"@sentry/core" "9.15.0"
"@sentry/opentelemetry" "9.15.0"
import-in-the-middle "^1.13.0"
"@sentry/opentelemetry@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry/opentelemetry/-/opentelemetry-9.14.0.tgz#7454f23a4add2098a1364b8eea966c057503223e"
integrity sha512-NnHJjSQGpWaZ6+0QK9Xn1T3CTOM16Ij07VnSiGmVz3/IMsNC1/jndqc8p9BxEI+67XhZjOUUN0Ogpq1XRY7YeA==
"@sentry/opentelemetry@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry/opentelemetry/-/opentelemetry-9.15.0.tgz#1888c8a08e69a49345d6a161a6dbc04fe6c7744f"
integrity sha512-gGOzgSxbuh4B4SlEonL1LFsazmeqL/fn5CIQqRG0UWWxdt6TKAMlj0ThIlGF3jSHW2eXdpvs+4E73uFEaHIqfg==
dependencies:
"@sentry/core" "9.14.0"
"@sentry/core" "9.15.0"
"@sentry/profiling-node@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry/profiling-node/-/profiling-node-9.14.0.tgz#367dcc184f026f263a6faf0080acc26ad4215635"
integrity sha512-DDbjWdkb/Hh0SOgzONbufUW7oqyTaHQu+oLWQidNaSHk57M3P/Rz8klxIrYRygvlZJQpd5RKJMpT8G+ceUBz0A==
"@sentry/profiling-node@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry/profiling-node/-/profiling-node-9.15.0.tgz#04c7588b8f805f521178e2ef859a482ea21b1e80"
integrity sha512-I5lU5XNYOTeULAUgeZg9Un+nKmwkDKCQ9R6B+RUkT73Lz+xI6miriEfPcMXbeOcTwU6XygKHpcm+0Ksi80QJYA==
dependencies:
"@sentry-internal/node-cpu-profiler" "^2.0.0"
"@sentry/core" "9.14.0"
"@sentry/node" "9.14.0"
"@sentry/core" "9.15.0"
"@sentry/node" "9.15.0"
"@sentry/react@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-9.14.0.tgz#1fa021b5c1a4171356ee391b8ef9652bcf076e59"
integrity sha512-0dRfTorcInBjxVnis6Zv0+Jqex2OXFNQf+cQanKuC0IRkAhZyD2+UO2/v39sSmtvrHIcZRQ9fta8qKdhFUXCqg==
"@sentry/react@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-9.15.0.tgz#1b0f3b79180bd4359afb4c68da1f19fd866ff643"
integrity sha512-8nojSjiEd/EWIgoWVfkNIkBGL2yoFZoVMBUTcYlypsMnUHNko2RJItOBqZs5/DRBxuzfBKVt8PF+gkhQOm6mPg==
dependencies:
"@sentry/browser" "9.14.0"
"@sentry/core" "9.14.0"
"@sentry/browser" "9.15.0"
"@sentry/core" "9.15.0"
hoist-non-react-statics "^3.3.2"
"@sentry/vercel-edge@9.14.0":
version "9.14.0"
resolved "https://registry.yarnpkg.com/@sentry/vercel-edge/-/vercel-edge-9.14.0.tgz#9e36070f4199161b2c7c31a81f0f905d83297701"
integrity sha512-qcQddIRNFJbDaBuGFfEthwMgR7+wsPfMVwMurQdxS7Axr3GgT03wVG/SPAVS/yhVLeqXrfeHaqJaNg05z0aErA==
"@sentry/vercel-edge@9.15.0":
version "9.15.0"
resolved "https://registry.yarnpkg.com/@sentry/vercel-edge/-/vercel-edge-9.15.0.tgz#e9e89dd922f5d322bcf42592cfbb47e4c0e5b771"
integrity sha512-Rfc6pDbHMg5DMIgyZHVIO4IeHgxcH3myPBy9HP1hMLtcEqKL/YS8dK3oQrZoUsNP9chjXkrp4bBeKT/phX3pMg==
dependencies:
"@opentelemetry/api" "^1.9.0"
"@sentry/core" "9.14.0"
"@sentry/core" "9.15.0"
"@sentry/webpack-plugin@3.3.1":
version "3.3.1"
@@ -4869,29 +4881,29 @@
dependencies:
"@typescript-eslint/utils" "^8.18.1"
"@tanstack/query-core@5.74.9":
version "5.74.9"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.74.9.tgz#35d5b1075663072bea22aa3ce21508b195306ecd"
integrity sha512-qmjXpWyigDw4SfqdSBy24FzRvpBPXlaSbl92N77lcrL+yvVQLQkf0T6bQNbTxl9IEB/SvVFhhVZoIlQvFnNuuw==
"@tanstack/query-core@5.75.4":
version "5.75.4"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.75.4.tgz#e05f2fe4145fb5354271ad19e63eec61f6ce3012"
integrity sha512-pcqOUgWG9oGlzkfRQQMMsEFmtQu0wq81A414CtELZGq+ztVwSTAaoB3AZRAXQJs88LmNMk2YpUKuQbrvzNDyRg==
"@tanstack/query-devtools@5.74.7":
version "5.74.7"
resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.74.7.tgz#c9b022b386ac86e6395228b5d6912e6444b3b971"
integrity sha512-nSNlfuGdnHf4yB0S+BoNYOE1o3oAH093weAYZolIHfS2stulyA/gWfSk/9H4ZFk5mAAHb5vNqAeJOmbdcGPEQw==
"@tanstack/react-query-devtools@5.74.9":
version "5.74.9"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.74.9.tgz#7d79e27b2c4d949c9c1e269eafdb7c2256f6d493"
integrity sha512-6dMfeK/5OvC9E88/ziwiv1Pggqkgjker8V+pLJFrjh7O7E7S6yXJRNNr/KjA/c+z6d/i7HpDk8FF+oSr7mhYLg==
"@tanstack/react-query-devtools@5.75.4":
version "5.75.4"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.75.4.tgz#89614363d63c997ade81ade4a18e90b57512d4d8"
integrity sha512-CSJZWa316EFtLZtr6RQLAnqWb1MESzyZ7j0bMQjuhYas5FDp/3MA7G6RE4hWauqCCDsNIfIm2Rnm1zJTZVye/w==
dependencies:
"@tanstack/query-devtools" "5.74.7"
"@tanstack/react-query@5.74.9":
version "5.74.9"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.74.9.tgz#fdcac4a31cd7f5786eda7526ee70ed6c63d13799"
integrity sha512-F8xCXDQRDgsPzLzX9+d6ycNoITAIU2bycc1idZd06bt/GjN1quEJDjHvEDWZGoVn0A/ZmntVrYv6TE0kR7c7LA==
"@tanstack/react-query@5.75.4":
version "5.75.4"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.75.4.tgz#721e1cdf7debb110671f558dc2b6276f637554a5"
integrity sha512-Vf65pzYRkf8fk9SP1ncIZjvaXszBhtsvpf+h45Y/9kOywOrVZfBGUpCdffdsVzbmBzmz6TCFes9bM0d3pRrIsA==
dependencies:
"@tanstack/query-core" "5.74.9"
"@tanstack/query-core" "5.75.4"
"@tanstack/react-table@8.20.6":
version "8.20.6"
@@ -5375,10 +5387,10 @@
dependencies:
"@types/node" "*"
"@types/node@*", "@types/node@22.10.7", "@types/node@22.15.3", "@types/node@^22.7.5":
version "22.15.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.3.tgz#b7fb9396a8ec5b5dfb1345d8ac2502060e9af68b"
integrity sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==
"@types/node@*", "@types/node@22.10.7", "@types/node@22.15.12", "@types/node@^22.7.5":
version "22.15.12"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.12.tgz#9ce54e51e09536faa94e4ec300c4728ee83bfa85"
integrity sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==
dependencies:
undici-types "~6.21.0"
@@ -5434,10 +5446,10 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/react-dom@*", "@types/react-dom@19.1.2":
version "19.1.2"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.2.tgz#bd1fe3b8c28a3a2e942f85314dcfb71f531a242f"
integrity sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==
"@types/react-dom@*", "@types/react-dom@19.1.3":
version "19.1.3"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.3.tgz#3f0c60804441bf34d19f8dd0d44405c0c0e21bfa"
integrity sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==
"@types/react-modal@3.16.3":
version "3.16.3"
@@ -5451,10 +5463,10 @@
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044"
integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==
"@types/react@*", "@types/react@19.1.2":
version "19.1.2"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.2.tgz#11df86f66f188f212c90ecb537327ec68bfd593f"
integrity sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==
"@types/react@*", "@types/react@19.1.3":
version "19.1.3"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.3.tgz#c75a24b775a63280b02c66a55a3cfa04f4022cf7"
integrity sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==
dependencies:
csstype "^3.0.2"
@@ -5564,30 +5576,30 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@*", "@typescript-eslint/eslint-plugin@8.31.1", "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.31.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz#62f1befe59647524994e89de4516d8dcba7a850a"
integrity sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==
"@typescript-eslint/eslint-plugin@*", "@typescript-eslint/eslint-plugin@8.32.0", "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.32.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz#86630dd3084f9d6c4239bbcd6a7ee1a7ee844f7f"
integrity sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.31.1"
"@typescript-eslint/type-utils" "8.31.1"
"@typescript-eslint/utils" "8.31.1"
"@typescript-eslint/visitor-keys" "8.31.1"
"@typescript-eslint/scope-manager" "8.32.0"
"@typescript-eslint/type-utils" "8.32.0"
"@typescript-eslint/utils" "8.32.0"
"@typescript-eslint/visitor-keys" "8.32.0"
graphemer "^1.4.0"
ignore "^5.3.1"
natural-compare "^1.4.0"
ts-api-utils "^2.0.1"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@*", "@typescript-eslint/parser@8.31.1", "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.31.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.31.1.tgz#e9b0ccf30d37dde724ee4d15f4dbc195995cce1b"
integrity sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==
"@typescript-eslint/parser@*", "@typescript-eslint/parser@8.32.0", "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.32.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.32.0.tgz#fe840ecb2726a82fa9f5562837ec40503ae71caf"
integrity sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==
dependencies:
"@typescript-eslint/scope-manager" "8.31.1"
"@typescript-eslint/types" "8.31.1"
"@typescript-eslint/typescript-estree" "8.31.1"
"@typescript-eslint/visitor-keys" "8.31.1"
"@typescript-eslint/scope-manager" "8.32.0"
"@typescript-eslint/types" "8.32.0"
"@typescript-eslint/typescript-estree" "8.32.0"
"@typescript-eslint/visitor-keys" "8.32.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.31.1", "@typescript-eslint/scope-manager@^8.15.0":
@@ -5598,21 +5610,34 @@
"@typescript-eslint/types" "8.31.1"
"@typescript-eslint/visitor-keys" "8.31.1"
"@typescript-eslint/type-utils@8.31.1":
version "8.31.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz#be0f438fb24b03568e282a0aed85f776409f970c"
integrity sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==
"@typescript-eslint/scope-manager@8.32.0":
version "8.32.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz#6be89f652780f0d3d19d58dc0ee107b1a9e3282c"
integrity sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==
dependencies:
"@typescript-eslint/typescript-estree" "8.31.1"
"@typescript-eslint/utils" "8.31.1"
"@typescript-eslint/types" "8.32.0"
"@typescript-eslint/visitor-keys" "8.32.0"
"@typescript-eslint/type-utils@8.32.0":
version "8.32.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz#5e0882393e801963f749bea38888e716045fe895"
integrity sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==
dependencies:
"@typescript-eslint/typescript-estree" "8.32.0"
"@typescript-eslint/utils" "8.32.0"
debug "^4.3.4"
ts-api-utils "^2.0.1"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.31.1":
version "8.31.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.31.1.tgz#478ed6f7e8aee1be7b63a60212b6bffe1423b5d4"
integrity sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==
"@typescript-eslint/types@8.32.0":
version "8.32.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.32.0.tgz#a4a66b8876b8391970cf069b49572e43f1fc957a"
integrity sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==
"@typescript-eslint/typescript-estree@8.31.1":
version "8.31.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz#37792fe7ef4d3021c7580067c8f1ae66daabacdf"
@@ -5627,7 +5652,31 @@
semver "^7.6.0"
ts-api-utils "^2.0.1"
"@typescript-eslint/utils@8.31.1", "@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/utils@^8.15.0", "@typescript-eslint/utils@^8.18.1":
"@typescript-eslint/typescript-estree@8.32.0":
version "8.32.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz#11d45f47bfabb141206a3da6c7b91a9d869ff32d"
integrity sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==
dependencies:
"@typescript-eslint/types" "8.32.0"
"@typescript-eslint/visitor-keys" "8.32.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
minimatch "^9.0.4"
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.32.0":
version "8.32.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.32.0.tgz#24570f68cf845d198b73a7f94ca88d8c2505ba47"
integrity sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.32.0"
"@typescript-eslint/types" "8.32.0"
"@typescript-eslint/typescript-estree" "8.32.0"
"@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/utils@^8.15.0", "@typescript-eslint/utils@^8.18.1":
version "8.31.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.31.1.tgz#5628ea0393598a0b2f143d0fc6d019f0dee9dd14"
integrity sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==
@@ -5645,6 +5694,14 @@
"@typescript-eslint/types" "8.31.1"
eslint-visitor-keys "^4.2.0"
"@typescript-eslint/visitor-keys@8.32.0":
version "8.32.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz#0cca2cac046bc71cc40ce8214bac2850d6ecf4a6"
integrity sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==
dependencies:
"@typescript-eslint/types" "8.32.0"
eslint-visitor-keys "^4.2.0"
"@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.2.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
@@ -7410,7 +7467,7 @@ emittery@^0.13.1:
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
emoji-mart@^5.6.0:
emoji-mart@5.6.0, emoji-mart@^5.6.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023"
integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==
@@ -7787,10 +7844,10 @@ eslint-plugin-playwright@2.2.0:
dependencies:
globals "^13.23.0"
eslint-plugin-prettier@5.2.6:
version "5.2.6"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz#be39e3bb23bb3eeb7e7df0927cdb46e4d7945096"
integrity sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==
eslint-plugin-prettier@5.4.0:
version "5.4.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.0.tgz#54d4748904e58eaf1ffe26c4bffa4986ca7f952b"
integrity sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==
dependencies:
prettier-linter-helpers "^1.0.0"
synckit "^0.11.0"
@@ -8964,10 +9021,10 @@ hyphen@^1.6.4:
resolved "https://registry.yarnpkg.com/hyphen/-/hyphen-1.10.6.tgz#0e779d280e696102b97d7e42f5ca5de2cc97e274"
integrity sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==
i18next-browser-languagedetector@8.0.5:
version "8.0.5"
resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.5.tgz#6cfdc72820457ce95e69a2788a4f837d1d8f4e9d"
integrity sha512-OstebRKqKiQw8xEvQF5aRyUujsCatanj7Q9eo5iiH2gJpoXGZ7483ol3sVBwfqbobTQPNH1J+NAyJ1aCQoEC+w==
i18next-browser-languagedetector@8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz#e7fcc1084e9ca8fece329c8c05b000a6e25bd82e"
integrity sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==
dependencies:
"@babel/runtime" "^7.23.2"
@@ -8994,10 +9051,10 @@ i18next-parser@9.3.0:
vinyl "^3.0.0"
vinyl-fs "^4.0.0"
i18next@25.0.2:
version "25.0.2"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.0.2.tgz#3bfd51d11603e130cc4a88ff37038393dc560951"
integrity sha512-xWxgK8GAaPYkV9ia2tdgbtdM+qiC+ysVTBPvXhpCORU/+QkeQe3BSI7Crr+c4ZXULN1PfnXG/HY2n7HGx4KKBg==
i18next@25.1.1:
version "25.1.1"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.1.1.tgz#113e7773697d300e7e824185f766362eceb8dc61"
integrity sha512-FZcp3vk3PXc8onasbsWYahfeDIWX4LkKr4vd01xeXrmqyNXlVNtVecEIw2K1o8z3xYrHMcd1bwYQub+3g7zqCw==
dependencies:
"@babel/runtime" "^7.26.10"
@@ -11565,10 +11622,10 @@ postgres-range@^1.1.1:
resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863"
integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==
posthog-js@1.236.8:
version "1.236.8"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.236.8.tgz#0576c6ffc17ba76f869e82dfda82dccaab084479"
integrity sha512-Nmd1LP9lAairJ2gkIIHNHeY8c7F6nO0g6OWaAHe6MfRnKWwnzBoLkDXEX4ptvooQVKnQ3eS7UR0Q+112TAn9qw==
posthog-js@1.239.1:
version "1.239.1"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.239.1.tgz#871c734a438f2ead1529deba26031d689819d587"
integrity sha512-4cN3A02e9uyyTVQ/9+bGVfvoAmpM1mlVXlq2DsujCQjloMMSbEb+6Uwv6fro3Q5qk7NEU62kQqZHyvqQIqv9zA==
dependencies:
core-js "^3.38.1"
fflate "^0.4.8"
@@ -11749,10 +11806,10 @@ prosemirror-menu@^1.2.4:
prosemirror-history "^1.0.0"
prosemirror-state "^1.0.0"
prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0:
version "1.25.1"
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.1.tgz#aeae9f1ec79fcaa76f6fc619800d91fbcf726870"
integrity sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==
prosemirror-model@1.25.0, prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0:
version "1.25.0"
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.0.tgz#c147113edc0718a14f03881e4c20367d0221f7af"
integrity sha512-/8XUmxWf0pkj2BmtqZHYJipTBMHIdVjuvFzMvEoxrtyGNmfvdhBiRwYt/eFwy2wA9DtBW3RLqvZnjurEkHaFCw==
dependencies:
orderedmap "^2.0.0"
@@ -13830,7 +13887,7 @@ trough@^2.0.0:
resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f"
integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==
ts-api-utils@^2.0.1:
ts-api-utils@^2.0.1, ts-api-utils@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91"
integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==
@@ -13870,10 +13927,10 @@ ts-node@10.9.2:
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
tsc-alias@1.8.15:
version "1.8.15"
resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.8.15.tgz#7a07a77a4157872f834841a2a1647fad9464884d"
integrity sha512-yKLVx8ddUurRwhVcS6JFF2ZjksOX2ZWDRIdgt+PQhJBDegIdAdilptiHsuAbx9UFxa16GFrxeKQ2kTcGvR6fkQ==
tsc-alias@1.8.16:
version "1.8.16"
resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.8.16.tgz#dbc74e797071801c7284f1a478259de920f852d4"
integrity sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==
dependencies:
chokidar "^3.5.3"
commander "^9.0.0"
@@ -14861,16 +14918,21 @@ write-file-atomic@^5.0.1:
imurmurhash "^0.1.4"
signal-exit "^4.0.1"
ws@8.18.1, ws@^8.11.0, ws@^8.17.1, ws@^8.18.0, ws@^8.5.0:
version "8.18.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb"
integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==
ws@8.18.2:
version "8.18.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.2.tgz#42738b2be57ced85f46154320aabb51ab003705a"
integrity sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==
ws@^7.4.6:
version "7.5.10"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.11.0, ws@^8.17.1, ws@^8.18.0, ws@^8.5.0:
version "8.18.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb"
integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==
xml-js@^1.6.8:
version "1.6.11"
resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9"
@@ -14972,10 +15034,10 @@ yoga-layout@^3.2.1:
resolved "https://registry.yarnpkg.com/yoga-layout/-/yoga-layout-3.2.1.tgz#d2d1ba06f0e81c2eb650c3e5ad8b0b4adde1e843"
integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==
zustand@5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.3.tgz#b323435b73d06b2512e93c77239634374b0e407f"
integrity sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==
zustand@5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.4.tgz#33af161f1e337854ccd8b711ef9e92545d6ae53f"
integrity sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==
zwitch@^2.0.0, zwitch@^2.0.4:
version "2.0.4"

View File

@@ -0,0 +1,123 @@
{
"footer": {
"default": {
"externalLinks": [
{
"label": "Github",
"href": "https://github.com/suitenumerique/docs/"
},
{
"label": "DINUM",
"href": "https://www.numerique.gouv.fr/dinum/"
},
{
"label": "ZenDiS",
"href": "https://zendis.de/"
},
{
"label": "BlockNote.js",
"href": "https://www.blocknotejs.org/"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence etalab-2.0",
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
}
}
},
"en": {
"legalLinks": [
{
"label": "Legal Notice",
"href": "#"
},
{
"label": "Personal data and cookies",
"href": "#"
},
{
"label": "Accessibility",
"href": "#"
}
],
"bottomInformation": {
"label": "Unless otherwise stated, all content on this site is under",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"fr": {
"legalLinks": [
{
"label": "Mentions légales",
"href": "#"
},
{
"label": "Données personnelles et cookies",
"href": "#"
},
{
"label": "Accessibilité",
"href": "#"
}
],
"bottomInformation": {
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"de": {
"legalLinks": [
{
"label": "Impressum",
"href": "#"
},
{
"label": "Personenbezogene Daten und Cookies",
"href": "#"
},
{
"label": "Barrierefreiheit",
"href": "#"
}
],
"bottomInformation": {
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
},
"nl": {
"legalLinks": [
{
"label": "Wettelijke bepalingen",
"href": "#"
},
{
"label": "Persoonlijke gegevens en cookies",
"href": "#"
},
{
"label": "Toegankelijkheid",
"href": "#"
}
],
"bottomInformation": {
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
"link": {
"label": "licence MIT",
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
}
}
}
}
}

View File

@@ -56,6 +56,7 @@ backend:
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
DJANGO_CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root
AWS_S3_SECRET_ACCESS_KEY: password
@@ -63,6 +64,7 @@ backend:
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
migrate:
command:
- "/bin/sh"
@@ -87,13 +89,17 @@ backend:
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
themeCustomization:
enabled: true
file_content: {{ readFile "./configuration/theme/demo.json" }}
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volumes to manage our local custom CA and avoid to set ssl_verify: false
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
@@ -101,7 +107,6 @@ backend:
items:
- key: cacert.pem
path: cacert.pem
frontend:
envVars:
PORT: 8080

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