Compare commits

...

46 Commits

Author SHA1 Message Date
lebaudantoine 4cecfec0b7 🩹(frontend) correct misleading error message for config fetch failure
Update APIError message that incorrectly mentioned document when
configuration fetching fails.
2025-05-25 00:10:20 +02:00
lebaudantoine ec663b601f 🚸(auth) implement first draft of silent login with prompt=None
Add initial naive implementation of silent login functionality using
prompt=None parameter to request OIDC provider to skip login screen and
return proper HTTP error response instead.
2025-05-25 00:10:09 +02:00
lebaudantoine 4de26c331b ️(frontend) optimize document fetch error handling
Reduce unnecessary fetch requests when retrieving documents with permission
or authentication issues. Previous implementation was triggering multiple
document requests despite having sufficient error information from initial
attempt to determine appropriate user redirection.

Additionally, fix issue where resetting the auth cache was triggering redundant
authentication verification requests. The responsibility for checking auth
status should belong to the 401 page component on mount, rather than being
triggered by cache resets during error handling.

Known limitations:
- Not waiting for async  function completion makes code harder to
 maintain
- Added loading spinner as temporary solution to prevent UI flicker
- Future improvement should implement consistent error-based redirects rather
 than rendering error messages directly on document page
2025-05-25 00:09:54 +02:00
lebaudantoine f66a8e3f1d 🔥(frontend) delete unused variable
Remove variable that is no longer referenced in the codebase to improve
code cleanliness and reduce potential confusion.
2025-05-25 00:09:54 +02:00
lebaudantoine 4f3e0ef5b3 ️(frontend) implement Mozilla Django OIDC returnTo for login flow
Use returnTo capability to pass target URL during authentication, eliminating
redundant renders when returning to app. Work around Next.js router limitations
(can't pass data when replacing URL) by adding temporary target parameter.

Discovered issue: 401 responses on document fetch trigger retry loop instead
of redirecting to login. Will address in subsequent commits.
2025-05-25 00:09:50 +02:00
lebaudantoine 7b363d4624 ️(frontend) prevent authentication retry on 401 responses
Stop retry attempts when receiving 401 Unauthorized from /me endpoint since
this clearly indicates authentication status. The original purpose of the /me
call is simply to determine if user is authenticated, and a 401 provides
sufficient information.

Prevents unnecessary network requests caused by React Query's automatic retry
behavior when re-raising exceptions, which was hiding the 401 status. Improves
performance and reduces server load during authentication failures.
2025-05-25 00:09:32 +02:00
virgile-dev bf999979d2 📝(doc) update xl packages warning (#985)
So that people know how to use the PUBLISH_AS_MIT variable

Signed-off-by: virgile-dev <virgile.deville@beta.gouv.fr>
2025-05-20 21:10:54 +02:00
renovate[bot] 09d3ff3754 ⬆️(dependencies) update python dependencies 2025-05-19 12:21:04 +00:00
Samuel Paccoud - DINUM 6e5d005dee (backend) allow setting session cookie age via env var
We want to be able to increase the duration of the cookie session
by setting an environment variable.
2025-05-19 13:57:30 +02:00
Anthony LC 6377c8fcca ✈️(frontend) allow editing when offline
When the user is offline, we allow editing the
document in the editor.
Their is not a reliable way to know if the user is
offline or online except by doing a network request
and checking if an error is thrown or not.
To do so, we created the OfflinePlugin inherited
from the WorkboxPlugin.
It will inform us if the user is offline or online.
We then dispatch the information to our application
thanks to the useOffline hook.
2025-05-19 12:36:32 +02:00
Anthony LC 3c8cacc048 🛂(frontend) block edition to not connected users
If an editor is working on a shared document but
is not connected to the collaborative server
we are now blocking the edition.
It is to avoid none connected users to
overwrite the document with connected
users.
2025-05-19 12:36:31 +02:00
virgile-dev 598fb4fa27 📝(doc) update issue templates (#976)
Mention of Impress instead of Docs is confusing. Also added some
automatic labelling.

Signed-off-by: virgile-dev <virgile.deville@beta.gouv.fr>
2025-05-19 09:14:00 +00:00
Anthony LC 51618ad081 📌(dependencies) add hocuspocus to renovate ignored list
hocuspocus > 3.0.0 brings breaking changes.
Let's add it in the renovate.json ignored list
until we decide to upgrade it.
2025-05-19 09:19:37 +02:00
renovate[bot] 8109d5ba08 ⬆️(dependencies) update js dependencies 2025-05-19 09:17:33 +02:00
virgile-dev e4d0179bbe 📝(doc) readme update (#974)
Added badges so people can assess the repo activity easily. 
Also update the paragraph about AGPL XL packages since this was merged :
https://github.com/suitenumerique/docs/pull/911

---------

Signed-off-by: virgile-dev <virgile.deville@beta.gouv.fr>
2025-05-16 17:48:57 +00:00
Manuel Raynaud 9d3dfb6de7 ⬆️(docker) upgrade node images to alpine 3.21
We need to upgrade our images to alpine 3.21 in order to fix a CVE
related to libxml2. We also upgrade node to version 24
2025-05-16 15:55:33 +02:00
Manuel Raynaud 0da042f887 ⬆️(docker) upgrade backend image to python 3.13
Python 3.13 is now stable, our libraries are compatible with it. We also
upgrade the alpine version used in order to fix CVE related to libxml2
2025-05-16 15:55:32 +02:00
Anthony LC 6cd0cd0689 ⬆️(dependencies) gouvfr-lasuite/ui-kit to 0.6.0
Upgrade @gouvfr-lasuite/ui-kit from 0.5.0 to 0.6.0.
Some properties have been removed in 0.5.0, which
causes the design of the app to be broken.
Version 0.6.0 has as well some breaking changes,
about the "logo" properties that are not available anymore.
We fix them in this commit.
2025-05-16 12:35:16 +02:00
Anthony LC 10b088599c 🐛(frontend) fix svg export
Last upgrade of Blocknote to 0.30.0 broke the SVG
export. The previewWidth can be undefined, which causes the
export to fail. This commit adds a fallback
width in case previewWidth is undefined.
2025-05-16 11:12:30 +02:00
Anthony LC 62d1bc6473 🐛(frontend) redirect to /home
The page '/login' was replaced with '/home',
but some users may still have the old URL in their
bookmarks, it can create a loop during the
authentication process.
We redirect the user to '/home' if they try to access
'/login' page, it will prevent edge cases.
2025-05-16 11:12:30 +02:00
Anthony LC fc1d33268c ⬆️(dependencies) update js dependencies 2025-05-16 11:12:30 +02:00
virgile-dev 95833fa5ec 📝(documentation) add banner to readme (#970)
Implement suggestions made by @xibe in
[#848](https://github.com/suitenumerique/docs/pull/848) and
[#849](https://github.com/suitenumerique/docs/pull/849)

Signed-off-by: virgile-dev <virgile.deville@beta.gouv.fr>
2025-05-16 11:10:05 +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
150 changed files with 4865 additions and 2819 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
---
name: 🐛 Bug Report
about: If something is not working as expected 🤔.
labels: ["bug", "triage"]
---
## Bug Report
@@ -18,8 +18,8 @@ A clear and concise description of what you expected to happen (or code).
3. And then the bug happens!
**Environment**
- Impress version:
- Platform:
- Docs version:
- Instance url:
**Possible Solution**
<!--- Only if you have suggestions on a fix for the bug -->
+3 -3
View File
@@ -1,7 +1,7 @@
---
name: ✨ Feature Request
about: I have a suggestion (and may want to build it 💪)!
labels: ["feature", "triage"]
---
## Feature Request
@@ -16,8 +16,8 @@ A clear and concise description of what you want to happen. Add any considered d
A clear and concise description of any alternative solutions or features you've considered.
**Discovery, Documentation, Adoption, Migration Strategy**
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
Maybe a screenshot or design?
If you can, explain how users will be able to use this and possibly write out some documentation (if applicable).
Maybe add a screenshot or design?
**Do you want to work on it through a Pull Request?**
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
+5 -9
View File
@@ -1,17 +1,13 @@
---
name: 🤗 Support Question
about: If you have a question 💬, or something was not clear from the docs!
labels: ["support", "triage"]
---
## Support request
**Checks before filing**
Please make sure you have read our [main Readme](https://github.com/suitenumerique/docs).
<!-- ^ Click "Preview" for a nicer view! ^
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
---
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/impress).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/impress/issues).
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/docs/issues?q=is%3Aissue%20state%3Aopen%20label%3Asupport).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
python-version: "3.13.3"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
+3 -1
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 }}
+21 -2
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:
@@ -72,7 +91,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
python-version: "3.13.3"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
@@ -167,7 +186,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
python-version: "3.13.3"
- name: Install development dependencies
run: pip install --user .[dev]
+26 -1
View File
@@ -8,11 +8,35 @@ and this project adheres to
## [Unreleased]
## Added
- ✨(backend) allow setting session cookie age via env var #977
- ✨(backend) 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
- 🛂(frontend) block edition to not connected users #945
## Changed
- 📝(frontend) Update documentation
- ✅(frontend) Improve tests coverage
- ⬆️(docker) upgrade backend image to python 3.13 #973
- ⬆️(docker) upgrade node images to alpine 3.21
### 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
@@ -20,6 +44,7 @@ and this project adheres to
- 🚸(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
@@ -537,7 +562,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
+11 -7
View File
@@ -42,34 +42,38 @@ Examples of unacceptable behavior include:
## Enforcement Guidelines
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of the following Code of Conduct
## Code of Conduct:
1. Correction
### 1. Correction
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
2. Warning
### 2. Warning
Community Impact: A violation through a single incident or series of actions.
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
3. Temporary Ban
### 3. Temporary Ban
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
4. Permanent Ban
### 4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the community.
Attribution
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.
Community Impact Guidelines were inspired by Mozilla's [code of conduct enforcement ladder](https://github.com/mozilla/inclusion/blob/master/code-of-conduct-enforcement/consequence-ladder.md).
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
+21 -7
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).
+5 -2
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.13.3-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
@@ -30,7 +30,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:24 AS mail-builder
COPY ./src/mail /mail/app
@@ -139,6 +139,9 @@ CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core AS backend-production
# Remove apk cache, we don't need it anymore
RUN rm -rf /var/cache/apk/*
ARG IMPRESS_STATIC_ROOT=/data/static
# Gunicorn
+51 -36
View File
@@ -1,13 +1,19 @@
<p align="center">
<a href="https://github.com/suitenumerique/docs">
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
<img alt="Docs" src="/docs/assets/banner-docs.png" width="100%" />
</a>
</p>
<p align="center">
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
<a href="https://github.com/suitenumerique/docs/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/docs" alt="">
</a>
<a href='https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/docs"/>
</a>
</p>
<p align="center">
<a href="https://matrix.to/#/#docs-official:matrix.org">
Chat on Matrix
@@ -20,43 +26,52 @@ Welcome to Docs! The open source document editor where your notes can become kno
</a>
</p>
# La Suite Docs : Collaborative Text Editing
Docs, where your notes can become knowledge through live collaboration.
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
### Write
* 😌 Simple collaborative editing without the formatting complexity of markdown
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence.
### Collaborate
* 🤝 Collaborate with your team in real time
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 05/2025`
### Write
* 😌 Get simple, accessible online editing for your team.
* 💅 Create clean documents with beautiful formatting options.
* 🖌️ Focus on your content using either the in-line editor, or [the Markdown syntax](https://www.markdownguide.org/basic-syntax/).
* 🧱 Quickly design your page thanks to the many block types, accessible from the `/` slash commands, as well as keyboard shortcuts.
* 🔌 Write offline! Your edits will be synced once you're back online.
* ✨ Save time thanks to our AI actions, such as rephrasing, summarizing, fixing typos, translating, etc. You can even turn your selected text into a prompt!
### Work together
* 🤝 Enjoy live editing! See your team collaborate in real time.
* 🔒 Keep your information secure thanks to granular access control. Only share with the right people.
* 📑 Export your content in multiple formats (`.odt`, `.docx`, `.pdf`) with customizable templates.
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
🚀 Docs is easy to install on your own servers
⚠️ For the PDF and Docx export Docs relies on XL packages from BlockNote licenced in AGPL-3.0. Please make sure you fulfill your obligations regarding BlockNote licensing (see https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE and https://www.blocknotejs.org/about#partner-with-us).
Available methods: Helm chart, Nix package
In the works: Docker Compose, YunoHost
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
## Getting started 🔧
### Test it
Test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
### Run it locally
### Run Docs locally
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution but you can choose any S3 compatible object storage of your choice.
> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution.
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop, then type:
```shellscript
$ docker -v
@@ -68,7 +83,7 @@ $ docker compose version
Docker Compose version v2.32.4
```
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
> ⚠️ You may need to run the following commands with `sudo`, but this can be avoided by adding your user to the local `docker` group.
**Project bootstrap**
@@ -78,13 +93,13 @@ The easiest way to start working on the project is to use [GNU Make](https://www
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
This command builds the `app` container, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to <http://localhost:3000>.
You will be prompted to log in, the default credentials are:
You will be prompted to log in. The default credentials are:
```
username: impress
@@ -119,13 +134,13 @@ $ make run-backend
**Adding content**
You can create a basic demo site by running:
You can create a basic demo site by running this command:
```shellscript
$ make demo
```
Finally, you can check all available Make rules using:
Finally, you can check all available Make rules using this command:
```shellscript
$ make help
@@ -133,7 +148,7 @@ $ make help
**Django admin**
You can access the Django admin site at
You can access the Django admin site at:
<http://localhost:8071/admin>.
@@ -145,7 +160,7 @@ $ make superuser
## Feedback 🙋‍♂️🙋‍♀️
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
## Roadmap
@@ -155,7 +170,7 @@ Want to know where the project is headed? [🗺️ Checkout our roadmap](https:/
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
While Docs is a public-driven initiative, our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
@@ -163,9 +178,9 @@ This project is intended to be community-driven, so please, do not hesitate to [
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
If you intend to make pull requests, see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
Directory structure:
## Directory structure:
```markdown
docs
@@ -183,14 +198,14 @@ docs
### Stack
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/). We thank the contributors of all these projects for their awesome work!
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
### Gov ❤️ open source
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
<p align="center">
+1 -1
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.
+1 -2
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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

+50 -8
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,24 +49,22 @@ 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 | |
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| OIDC_CREATE_USER | create used on OIDC | false |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| 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 +74,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 |
@@ -98,3 +96,47 @@ 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.
Depending on how you are building the front-end application, this variable is used in different ways.
If you want to build the Docker image, this variable is used as an argument in the build command.
Example:
```
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest
```
If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`).
Example:
```
cd src/frontend/apps/impress
NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
```
| 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 | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE)
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
+2 -2
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:
+4 -4
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/
-2
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
+1
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
+2 -1
View File
@@ -26,9 +26,10 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@hocuspocus/provider",
"@hocuspocus/server",
"eslint",
"fetch-mock",
"prosemirror-model",
"node",
"node-fetch",
"workbox-webpack-plugin"
View File
+59 -24
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
+8
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"
+52
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)
+1 -1
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."
@@ -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"
@@ -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 {}
@@ -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),
@@ -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()
@@ -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. "
@@ -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,20 @@ 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["ContentType"] == "application/octet-stream"
assert file_head["Metadata"] == {
"owner": str(user.id),
"is_unsafe": "true",
"status": "processing",
}
# Depending the libmagic version, the content type may change.
assert file_head["ContentType"] in [
"application/x-dosexec",
"application/octet-stream",
]
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
@@ -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")
@@ -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"
@@ -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()
@@ -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()
+98 -2
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
-81
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
@@ -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)
-1
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()),
]
+5
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"]
+4 -1
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)
@@ -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"
}
}
}
}
}
+41 -14
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
@@ -462,9 +462,13 @@ class Base(Configuration):
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = 60 * 60 * 12
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
)
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",
@@ -549,6 +553,9 @@ class Base(Configuration):
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
environ_prefix=None,
)
OIDC_REDIRECT_FIELD_NAME = values.Value(
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
@@ -680,6 +687,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 +920,11 @@ class Production(Base):
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
"KEY_PREFIX": values.Value(
"docs",
environ_name="CACHES_KEY_PREFIX",
environ_prefix=None,
),
},
}
+9 -9
View File
@@ -26,19 +26,19 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.9",
"boto3==1.38.18",
"Brotli==1.1.0",
"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",
"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",
@@ -51,14 +51,14 @@ dependencies = [
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.77.0",
"psycopg[binary]==3.2.7",
"pycrdt==0.12.15",
"openai==1.79.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.19",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.3",
"sentry-sdk==2.27.0",
"sentry-sdk==2.28.0",
"whitenoise==6.9.0",
]
@@ -85,8 +85,8 @@ dev = [
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.8",
"types-requests==2.32.0.20250328",
"ruff==0.11.10",
"types-requests==2.32.0.20250515",
]
[tool.setuptools]
+22 -2
View File
@@ -1,4 +1,9 @@
FROM node:20-alpine AS frontend-deps
FROM node:24-alpine AS frontend-deps
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
WORKDIR /home/frontend/
@@ -39,10 +44,25 @@ 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
# Remove the upgrade part once nginx has published
# a new image that fixes the CVE related to libxml2
ARG UID=101
USER root
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
USER $UID
# Un-privileged user running the application
ARG DOCKER_USER
@@ -1,4 +1,9 @@
<svg width="100" height="100" viewBox="0 0 100 100">
<svg
width="100"
height="100"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="50" cy="30" r="20" fill="#3498db" />
<polygon
points="50,10 55,20 65,20 58,30 60,40 50,35 40,40 42,30 35,20 45,20"

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 336 B

@@ -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 (
@@ -102,7 +102,7 @@ export const verifyDocName = async (page: Page, docName: string) => {
export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
fillText: string = 'user ',
) => {
const responsePromiseSearchUser = page.waitForResponse(
@@ -4,6 +4,8 @@ import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import {
CONFIG,
addNewMember,
createDoc,
goToGridDoc,
mockedDocument,
@@ -363,7 +365,7 @@ test.describe('Doc Editor', () => {
partial_update: true,
retrieve: true,
},
link_reach: 'public',
link_reach: 'restricted',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
title: '',
@@ -452,4 +454,90 @@ test.describe('Doc Editor', () => {
const svgBuffer = await cs.toBuffer(await download.createReadStream());
expect(svgBuffer.toString()).toContain('Hello svg');
});
test('it checks block editing when not connected to collab server', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...CONFIG,
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
},
});
} else {
await route.continue();
}
});
await page.goto('/');
void page
.getByRole('button', {
name: 'New doc',
})
.click();
const card = page.getByLabel('It is the card information');
await expect(
card.getByText('Your network do not allow you to edit'),
).toBeHidden();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true');
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor', 'impress');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
await expect(
card.getByText('Your network do not allow you to edit'),
).toBeVisible({
timeout: 10000,
});
await expect(editor).toHaveAttribute('contenteditable', 'false');
});
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',
);
});
});
@@ -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
+1
View File
@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_SW_DEACTIVATED=
NEXT_PUBLIC_PUBLISH_AS_MIT=true
@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_PUBLISH_AS_MIT=false
NEXT_PUBLIC_SW_DEACTIVATED=true
+12
View File
@@ -40,6 +40,18 @@ tokens.themes.default.theme.colors = {
...customColors,
};
tokens.themes.default.theme = {
...tokens.themes.default.theme,
...{
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
},
};
tokens.themes.default.components = {
...tokens.themes.default.components,
...{
+1 -4
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).*/);
},
],
}),
+22 -19
View File
@@ -15,40 +15,43 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.2",
"@blocknote/code-block": "0.29.1",
"@blocknote/core": "0.29.1",
"@blocknote/mantine": "0.29.1",
"@blocknote/react": "0.29.1",
"@blocknote/xl-docx-exporter": "0.29.1",
"@blocknote/xl-pdf-exporter": "0.29.1",
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.30.0",
"@blocknote/core": "0.30.0",
"@blocknote/mantine": "0.30.0",
"@blocknote/react": "0.30.0",
"@blocknote/xl-docx-exporter": "0.30.0",
"@blocknote/xl-pdf-exporter": "0.30.0",
"@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",
"@gouvfr-lasuite/ui-kit": "0.6.0",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.0.0",
"@openfun/cunningham-react": "3.1.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.15.0",
"@tanstack/react-query": "5.75.4",
"@sentry/nextjs": "9.19.0",
"@tanstack/react-query": "5.76.1",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.4.1",
"i18next": "25.1.1",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"i18next": "25.1.3",
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.2",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.1",
"posthog-js": "1.239.1",
"next": "15.3.2",
"posthog-js": "1.242.2",
"react": "*",
"react-aria-components": "1.8.0",
"react-dom": "*",
"react-i18next": "15.5.1",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.1",
"styled-components": "6.1.17",
"styled-components": "6.1.18",
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",
"yjs": "*",
@@ -56,7 +59,7 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.75.4",
"@tanstack/react-query-devtools": "5.76.1",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
@@ -79,7 +82,7 @@
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"webpack": "5.99.7",
"webpack": "5.99.8",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -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"
}
}
}
}
@@ -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"
}
}
}
}
+29 -1
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;
};
@@ -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);
});
});
@@ -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/');
});
});
@@ -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');
});
});
@@ -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 });
});
});
@@ -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();
});
});
});
@@ -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}/`;
@@ -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;
};
+11 -3
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,
@@ -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[];
}
+18 -1
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
@@ -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;`}
`;
@@ -4,11 +4,13 @@ import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
import { Auth, KEY_AUTH } from '@/features/auth';
import { useResponsiveStore } from '@/stores/';
import { ConfigProvider } from './config/';
export const DEFAULT_QUERY_RETRY = 1;
/**
* QueryClient:
* - defaultOptions:
@@ -19,7 +21,7 @@ import { ConfigProvider } from './config/';
const defaultOptions = {
queries: {
staleTime: 1000 * 60 * 3,
retry: 1,
retry: DEFAULT_QUERY_RETRY,
},
};
const queryClient = new QueryClient({
@@ -35,8 +37,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
useEffect(() => {
const cleanupResizeListener = initializeResizeListener();
return cleanupResizeListener;
return initializeResizeListener();
}, [initializeResizeListener]);
useEffect(() => {
@@ -52,8 +53,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
void queryClient.resetQueries({
queryKey: [KEY_AUTH],
});
setAuthUrl();
void replace(`/401`);
void replace(
`/401?returnTo=${encodeURIComponent(window.location.pathname)}`,
);
}
},
},
@@ -38,7 +38,10 @@ export const getConfig = async (): Promise<ConfigResponse> => {
const response = await fetchAPI(`config/`);
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
throw new APIError(
'Failed to get the configurations',
await errorCauses(response),
);
}
const config = response.json() as Promise<ConfigResponse>;
@@ -53,13 +56,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;
}
@@ -78,37 +78,40 @@
--c--theme--colors--greyscale-750: #353535;
--c--theme--colors--greyscale-950: #1e1e1e;
--c--theme--colors--greyscale-1000: #161616;
--c--theme--colors--danger-050: #fff4f4;
--c--theme--colors--blue-500: #417dc4;
--c--theme--colors--brown-500: #bd987a;
--c--theme--colors--cyan-500: #009099;
--c--theme--colors--gold-500: #c3992a;
--c--theme--colors--green-500: #00a95f;
--c--theme--colors--olive-500: #68a532;
--c--theme--colors--orange-500: #e4794a;
--c--theme--colors--purple-500: #a558a0;
--c--theme--colors--red-500: #e1000f;
--c--theme--colors--yellow-500: #b7a73f;
--c--theme--colors--rose-500: #e18b76;
--c--theme--colors--primary-action: #1212ff;
--c--theme--colors--primary-bg: #fafafa;
--c--theme--colors--blue-400: #7ab1e8;
--c--theme--colors--blue-500: #417dc4;
--c--theme--colors--blue-600: #3558a2;
--c--theme--colors--brown-400: #e6be92;
--c--theme--colors--brown-500: #bd987a;
--c--theme--colors--brown-600: #745b47;
--c--theme--colors--cyan-400: #34bab5;
--c--theme--colors--cyan-500: #009099;
--c--theme--colors--cyan-600: #006a6f;
--c--theme--colors--gold-400: #ffca00;
--c--theme--colors--gold-500: #c3992a;
--c--theme--colors--gold-600: #695240;
--c--theme--colors--green-400: #34cb6a;
--c--theme--colors--green-500: #00a95f;
--c--theme--colors--green-600: #297254;
--c--theme--colors--olive-400: #99c221;
--c--theme--colors--olive-500: #68a532;
--c--theme--colors--olive-600: #447049;
--c--theme--colors--orange-400: #ff732c;
--c--theme--colors--orange-500: #e4794a;
--c--theme--colors--orange-600: #755348;
--c--theme--colors--pink-400: #ffb7ae;
--c--theme--colors--pink-500: #e18b76;
--c--theme--colors--pink-600: #8d533e;
--c--theme--colors--purple-400: #ce70cc;
--c--theme--colors--purple-500: #a558a0;
--c--theme--colors--purple-600: #6e445a;
--c--theme--colors--yellow-400: #d8c634;
--c--theme--colors--yellow-500: #b7a73f;
--c--theme--colors--yellow-600: #66673d;
--c--theme--font--sizes--h1: 2rem;
--c--theme--font--sizes--h2: 1.75rem;
@@ -192,6 +195,10 @@
--c--theme--logo--widthfooter: 220px;
--c--theme--logo--alt: gouvernement logo;
--c--components--modal--width-small: 342px;
--c--components--tooltip--padding: 4px 8px;
--c--components--tooltip--background-color: var(
--c--theme--colors--greyscale-1000
);
--c--components--button--medium-height: 40px;
--c--components--button--medium-text-height: 40px;
--c--components--button--border-radius: 4px;
@@ -448,6 +455,36 @@
--c--theme--colors--greyscale-950
);
--c--components--forms-select--font-size: 14px;
--c--components--badge--font-size: var(--c--theme--font--sizes--xs);
--c--components--badge--border-radius: 4px;
--c--components--badge--padding-inline: var(--c--theme--spacings--xs);
--c--components--badge--padding-block: var(--c--theme--spacings--2xs);
--c--components--badge--accent--background-color: var(
--c--theme--colors--primary-100
);
--c--components--badge--accent--color: var(--c--theme--colors--primary-600);
--c--components--badge--neutral--background-color: var(
--c--theme--colors--greyscale-100
);
--c--components--badge--neutral--color: var(
--c--theme--colors--greyscale-600
);
--c--components--badge--danger--background-color: var(
--c--theme--colors--danger-100
);
--c--components--badge--danger--color: var(--c--theme--colors--danger-600);
--c--components--badge--success--background-color: var(
--c--theme--colors--success-100
);
--c--components--badge--success--color: var(--c--theme--colors--success-600);
--c--components--badge--warning--background-color: var(
--c--theme--colors--warning-100
);
--c--components--badge--warning--color: var(--c--theme--colors--warning-600);
--c--components--badge--info--background-color: var(
--c--theme--colors--info-100
);
--c--components--badge--info--color: var(--c--theme--colors--info-600);
--c--components--la-gauffre--activated: true;
--c--components--home-proconnect--activated: true;
}
@@ -817,6 +854,54 @@
color: var(--c--theme--colors--greyscale-1000);
}
.clr-danger-050 {
color: var(--c--theme--colors--danger-050);
}
.clr-blue-500 {
color: var(--c--theme--colors--blue-500);
}
.clr-brown-500 {
color: var(--c--theme--colors--brown-500);
}
.clr-cyan-500 {
color: var(--c--theme--colors--cyan-500);
}
.clr-gold-500 {
color: var(--c--theme--colors--gold-500);
}
.clr-green-500 {
color: var(--c--theme--colors--green-500);
}
.clr-olive-500 {
color: var(--c--theme--colors--olive-500);
}
.clr-orange-500 {
color: var(--c--theme--colors--orange-500);
}
.clr-purple-500 {
color: var(--c--theme--colors--purple-500);
}
.clr-red-500 {
color: var(--c--theme--colors--red-500);
}
.clr-yellow-500 {
color: var(--c--theme--colors--yellow-500);
}
.clr-rose-500 {
color: var(--c--theme--colors--rose-500);
}
.clr-primary-action {
color: var(--c--theme--colors--primary-action);
}
@@ -829,10 +914,6 @@
color: var(--c--theme--colors--blue-400);
}
.clr-blue-500 {
color: var(--c--theme--colors--blue-500);
}
.clr-blue-600 {
color: var(--c--theme--colors--blue-600);
}
@@ -841,10 +922,6 @@
color: var(--c--theme--colors--brown-400);
}
.clr-brown-500 {
color: var(--c--theme--colors--brown-500);
}
.clr-brown-600 {
color: var(--c--theme--colors--brown-600);
}
@@ -853,10 +930,6 @@
color: var(--c--theme--colors--cyan-400);
}
.clr-cyan-500 {
color: var(--c--theme--colors--cyan-500);
}
.clr-cyan-600 {
color: var(--c--theme--colors--cyan-600);
}
@@ -865,10 +938,6 @@
color: var(--c--theme--colors--gold-400);
}
.clr-gold-500 {
color: var(--c--theme--colors--gold-500);
}
.clr-gold-600 {
color: var(--c--theme--colors--gold-600);
}
@@ -877,10 +946,6 @@
color: var(--c--theme--colors--green-400);
}
.clr-green-500 {
color: var(--c--theme--colors--green-500);
}
.clr-green-600 {
color: var(--c--theme--colors--green-600);
}
@@ -889,10 +954,6 @@
color: var(--c--theme--colors--olive-400);
}
.clr-olive-500 {
color: var(--c--theme--colors--olive-500);
}
.clr-olive-600 {
color: var(--c--theme--colors--olive-600);
}
@@ -901,10 +962,6 @@
color: var(--c--theme--colors--orange-400);
}
.clr-orange-500 {
color: var(--c--theme--colors--orange-500);
}
.clr-orange-600 {
color: var(--c--theme--colors--orange-600);
}
@@ -925,10 +982,6 @@
color: var(--c--theme--colors--purple-400);
}
.clr-purple-500 {
color: var(--c--theme--colors--purple-500);
}
.clr-purple-600 {
color: var(--c--theme--colors--purple-600);
}
@@ -937,10 +990,6 @@
color: var(--c--theme--colors--yellow-400);
}
.clr-yellow-500 {
color: var(--c--theme--colors--yellow-500);
}
.clr-yellow-600 {
color: var(--c--theme--colors--yellow-600);
}
@@ -1261,6 +1310,54 @@
background-color: var(--c--theme--colors--greyscale-1000);
}
.bg-danger-050 {
background-color: var(--c--theme--colors--danger-050);
}
.bg-blue-500 {
background-color: var(--c--theme--colors--blue-500);
}
.bg-brown-500 {
background-color: var(--c--theme--colors--brown-500);
}
.bg-cyan-500 {
background-color: var(--c--theme--colors--cyan-500);
}
.bg-gold-500 {
background-color: var(--c--theme--colors--gold-500);
}
.bg-green-500 {
background-color: var(--c--theme--colors--green-500);
}
.bg-olive-500 {
background-color: var(--c--theme--colors--olive-500);
}
.bg-orange-500 {
background-color: var(--c--theme--colors--orange-500);
}
.bg-purple-500 {
background-color: var(--c--theme--colors--purple-500);
}
.bg-red-500 {
background-color: var(--c--theme--colors--red-500);
}
.bg-yellow-500 {
background-color: var(--c--theme--colors--yellow-500);
}
.bg-rose-500 {
background-color: var(--c--theme--colors--rose-500);
}
.bg-primary-action {
background-color: var(--c--theme--colors--primary-action);
}
@@ -1273,10 +1370,6 @@
background-color: var(--c--theme--colors--blue-400);
}
.bg-blue-500 {
background-color: var(--c--theme--colors--blue-500);
}
.bg-blue-600 {
background-color: var(--c--theme--colors--blue-600);
}
@@ -1285,10 +1378,6 @@
background-color: var(--c--theme--colors--brown-400);
}
.bg-brown-500 {
background-color: var(--c--theme--colors--brown-500);
}
.bg-brown-600 {
background-color: var(--c--theme--colors--brown-600);
}
@@ -1297,10 +1386,6 @@
background-color: var(--c--theme--colors--cyan-400);
}
.bg-cyan-500 {
background-color: var(--c--theme--colors--cyan-500);
}
.bg-cyan-600 {
background-color: var(--c--theme--colors--cyan-600);
}
@@ -1309,10 +1394,6 @@
background-color: var(--c--theme--colors--gold-400);
}
.bg-gold-500 {
background-color: var(--c--theme--colors--gold-500);
}
.bg-gold-600 {
background-color: var(--c--theme--colors--gold-600);
}
@@ -1321,10 +1402,6 @@
background-color: var(--c--theme--colors--green-400);
}
.bg-green-500 {
background-color: var(--c--theme--colors--green-500);
}
.bg-green-600 {
background-color: var(--c--theme--colors--green-600);
}
@@ -1333,10 +1410,6 @@
background-color: var(--c--theme--colors--olive-400);
}
.bg-olive-500 {
background-color: var(--c--theme--colors--olive-500);
}
.bg-olive-600 {
background-color: var(--c--theme--colors--olive-600);
}
@@ -1345,10 +1418,6 @@
background-color: var(--c--theme--colors--orange-400);
}
.bg-orange-500 {
background-color: var(--c--theme--colors--orange-500);
}
.bg-orange-600 {
background-color: var(--c--theme--colors--orange-600);
}
@@ -1369,10 +1438,6 @@
background-color: var(--c--theme--colors--purple-400);
}
.bg-purple-500 {
background-color: var(--c--theme--colors--purple-500);
}
.bg-purple-600 {
background-color: var(--c--theme--colors--purple-600);
}
@@ -1381,10 +1446,6 @@
background-color: var(--c--theme--colors--yellow-400);
}
.bg-yellow-500 {
background-color: var(--c--theme--colors--yellow-500);
}
.bg-yellow-600 {
background-color: var(--c--theme--colors--yellow-600);
}
@@ -82,37 +82,40 @@ export const tokens = {
'greyscale-750': '#353535',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'danger-050': '#FFF4F4',
'blue-500': '#417DC4',
'brown-500': '#BD987A',
'cyan-500': '#009099',
'gold-500': '#C3992A',
'green-500': '#00A95F',
'olive-500': '#68A532',
'orange-500': '#E4794A',
'purple-500': '#A558A0',
'red-500': '#E1000F',
'yellow-500': '#B7A73F',
'rose-500': '#E18B76',
'primary-action': '#1212FF',
'primary-bg': '#FAFAFA',
'blue-400': '#7AB1E8',
'blue-500': '#417DC4',
'blue-600': '#3558A2',
'brown-400': '#E6BE92',
'brown-500': '#BD987A',
'brown-600': '#745B47',
'cyan-400': '#34BAB5',
'cyan-500': '#009099',
'cyan-600': '#006A6F',
'gold-400': '#FFCA00',
'gold-500': '#C3992A',
'gold-600': '#695240',
'green-400': '#34CB6A',
'green-500': '#00A95F',
'green-600': '#297254',
'olive-400': '#99C221',
'olive-500': '#68A532',
'olive-600': '#447049',
'orange-400': '#FF732C',
'orange-500': '#E4794A',
'orange-600': '#755348',
'pink-400': '#FFB7AE',
'pink-500': '#E18B76',
'pink-600': '#8D533E',
'purple-400': '#CE70CC',
'purple-500': '#A558A0',
'purple-600': '#6E445A',
'yellow-400': '#D8C634',
'yellow-500': '#B7A73F',
'yellow-600': '#66673D',
},
font: {
@@ -214,70 +217,65 @@ export const tokens = {
},
components: {
modal: { 'width-small': '342px' },
tooltip: { padding: '4px 8px', 'background-color': '#161616' },
button: {
'medium-height': '40px',
'medium-text-height': '40px',
'border-radius': '4px',
'small-height': '26px',
primary: {
'background--color': 'var(--c--theme--colors--primary-text)',
'background--color': '#000091',
'background--color-hover': '#1212ff',
'background--color-active': '#2323ff',
'background--color-disabled':
'var(--c--theme--colors--greyscale-100)',
'background--color-disabled': '#eee',
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
'color-focus-visible': '#fff',
disabled: 'var(--c--theme--colors--greyscale-500)',
disabled: '#7C7C7C',
},
'primary-text': {
'background--color': 'var(--c--theme--colors--primary-text)',
'background--color-hover': 'var(--c--theme--colors--greyscale-100)',
'background--color-active': 'var(--c--theme--colors--primary-100)',
'background--color': '#000091',
'background--color-hover': '#eee',
'background--color-active': '#ECECFE',
'background--color-focus-visible': '#fff',
'background--color-disabled':
'var(--c--theme--colors--greyscale-000)',
color: 'var(--c--theme--colors--primary-800)',
'color-hover': 'var(--c--theme--colors--primary-800)',
disabled: 'var(--c--theme--colors--greyscale-400)',
'background--color-disabled': '#fff',
color: '#000091',
'color-hover': '#000091',
disabled: '#929292',
},
secondary: {
'background--color-hover': '#F6F6F6',
'background--color-active': '#EDEDED',
'background--color-focus-visible':
'var(--c--theme--colors--greyscale-000)',
'background--disabled': 'var(--c--theme--colors--greyscale-000)',
color: 'var(--c--theme--colors--primary-800)',
'border--color': 'var(--c--theme--colors--greyscale-300)',
'border--color-hover': 'var(--c--theme--colors--greyscale-300)',
'border--color-disabled': 'var(--c--theme--colors--greyscale-300)',
disabled: 'var(--c--theme--colors--greyscale-400)',
'background--color-focus-visible': '#fff',
'background--disabled': '#fff',
color: '#000091',
'border--color': '#CECECE',
'border--color-hover': '#CECECE',
'border--color-disabled': '#CECECE',
disabled: '#929292',
},
tertiary: {
'background--color': 'var(--c--theme--colors--primary-100)',
'background--color-focus-visible':
'var(--c--theme--colors--primary-100)',
'background--color-hover': 'var(--c--theme--colors--primary-300)',
'background--color-active': 'var(--c--theme--colors--primary-300)',
'background--disabled': 'var(--c--theme--colors--primary-050)',
color: 'var(--c--theme--colors--primary-800)',
disabled: 'var(--c--theme--colors--primary-300)',
'background--color': '#ECECFE',
'background--color-focus-visible': '#ECECFE',
'background--color-hover': '#CACAFB',
'background--color-active': '#CACAFB',
'background--disabled': '#F5F5FE',
color: '#000091',
disabled: '#CACAFB',
},
'tertiary-text': {
'background--color-hover': 'var(--c--theme--colors--greyscale-100)',
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
'background--color-hover': '#eee',
'color-hover': '#000091',
color: '#313178',
},
danger: {
'color-hover': 'white',
'background--color': 'var(--c--theme--colors--danger-600)',
'background--color': '#CE0500',
'background--color-hover': '#FF2725',
'background--color-focus-visible':
'var(--c--theme--colors--danger-600)',
'background--color-disabled':
'var(--c--theme--colors--greyscale-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-400)',
'background--color-focus-visible': '#CE0500',
'background--color-disabled': '#eee',
'color-disabled': '#929292',
},
},
datagrid: {
@@ -288,22 +286,22 @@ export const tokens = {
},
'forms-checkbox': {
'border-radius': '4px',
'border-color': 'var(--c--theme--colors--primary-800)',
'background-color--hover': 'var(--c--theme--colors--greyscale-100)',
'border--color-disabled': 'var(--c--theme--colors--greyscale-200)',
'border--color': 'var(--c--theme--colors--primary-800)',
'background--disabled': 'var(--c--theme--colors--greyscale-200)',
'background--enable': 'var(--c--theme--colors--primary-800)',
'check--disabled': 'var(--c--theme--colors--greyscale-300)',
'check--enable': 'var(--c--theme--colors--greyscale-000)',
color: 'var(--c--theme--colors--primary-text)',
'label--color': 'var(--c--theme--colors--greyscale-1000)',
'label--size': 'var(--c--theme--font--sizes--sm)',
'border-color': '#000091',
'background-color--hover': '#eee',
'border--color-disabled': '#E5E5E5',
'border--color': '#000091',
'background--disabled': '#E5E5E5',
'background--enable': '#000091',
'check--disabled': '#CECECE',
'check--enable': '#fff',
color: '#000091',
'label--color': '#161616',
'label--size': '0.875rem',
'label--weight': '500',
'text--color': 'var(--c--theme--colors--greyscale-600)',
'text--size': 'var(--c--theme--font--sizes--s)',
'text--color': '#666666',
'text--size': '0.75rem',
'text--weight': '400',
'text--color-disabled': 'var(--c--theme--colors--greyscale-300)',
'text--color-disabled': '#CECECE',
},
'forms-labelledbox': {
'label-color--small': '#1E1E1E',
@@ -312,20 +310,18 @@ export const tokens = {
'label-color--big--disabled': '#CECECE',
},
'forms-radio': {
'border-color': 'var(--c--theme--colors--primary-800)',
'background-color': 'var(--c--theme--colors--greyscale-000)',
'accent-color': 'var(--c--theme--colors--primary-800)',
'accent-color-disabled': 'var(--c--theme--colors--greyscale-300)',
'border-color': '#000091',
'background-color': '#fff',
'accent-color': '#000091',
'accent-color-disabled': '#CECECE',
},
'forms-switch': {
'border--color-disabled': 'var(--c--theme--colors--greyscale-300)',
'border--color': 'var(--c--theme--colors--primary-800)',
'border--color-disabled': '#CECECE',
'border--color': '#000091',
'handle-background-color': 'white',
'handle-background-color--disabled':
'var(--c--theme--colors--greyscale-000)',
'rail-background-color--disabled':
'var(--c--theme--colors--greyscale-000)',
'accent-color': 'var(--c--theme--colors--primary-800)',
'handle-background-color--disabled': '#fff',
'rail-background-color--disabled': '#fff',
'accent-color': '#000091',
},
'forms-textarea': {
'label-color--focus': '#161616',
@@ -358,6 +354,18 @@ export const tokens = {
'value-color': '#1E1E1E',
'font-size': '14px',
},
badge: {
'font-size': '0.75rem',
'border-radius': '4px',
'padding-inline': '0.5rem',
'padding-block': '0.375rem',
accent: { 'background-color': '#ECECFE', color: '#313178' },
neutral: { 'background-color': '#eee', color: '#666666' },
danger: { 'background-color': '#FFE9E9', color: '#CE0500' },
success: { 'background-color': '#dffee6', color: '#18753c' },
warning: { 'background-color': '#fff4f3', color: '#b34000' },
info: { 'background-color': '#E8EDFF', color: '#0063CB' },
},
'la-gauffre': { activated: true },
'home-proconnect': { activated: true },
},
+1
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;
}
}
@@ -1,6 +1,11 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { DEFAULT_QUERY_RETRY } from '@/core';
import {
attemptSilentLogin,
canAttemptSilentLogin,
} from '@/features/auth/silentLogin';
import { User } from './types';
@@ -16,6 +21,16 @@ import { User } from './types';
*/
export const getMe = async (): Promise<User> => {
const response = await fetchAPI(`users/me/`);
if (!response.ok && response.status == 401 && canAttemptSilentLogin()) {
const currentLocation = window.location.href;
attemptSilentLogin(3600);
while (window.location.href === currentLocation) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (!response.ok) {
throw new APIError(
`Couldn't fetch user data: ${response.statusText}`,
@@ -34,6 +49,13 @@ export function useAuthQuery(
queryKey: [KEY_AUTH],
queryFn: getMe,
staleTime: 1000 * 60 * 15, // 15 minutes
retry: (failureCount, error) => {
// we assume that a 401 means the user is not logged in
if (error.status == 401) {
return false;
}
return failureCount < DEFAULT_QUERY_RETRY;
},
...queryConfig,
});
}
@@ -5,8 +5,9 @@ import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { useConfig } from '@/core';
import { HOME_URL } from '../conf';
import { useAuth } from '../hooks';
import { getAuthUrl, gotoLogin } from '../utils';
import { gotoLogin } from '../utils';
export const Auth = ({ children }: PropsWithChildren) => {
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
@@ -22,28 +23,12 @@ export const Auth = ({ children }: PropsWithChildren) => {
);
}
/**
* If the user is authenticated and wanted initially to access a document,
* we redirect to the document page.
*/
if (authenticated) {
const authUrl = getAuthUrl();
if (authUrl) {
void replace(authUrl);
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
}
/**
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
*/
if (!authenticated && !pathAllowed) {
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
void replace('/home');
void replace(HOME_URL);
} else {
gotoLogin();
}
@@ -57,7 +42,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
/**
* If the user is authenticated and the path is the home page, we redirect to the index.
*/
if (pathname === '/home' && authenticated) {
if (pathname === HOME_URL && authenticated) {
void replace('/');
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
@@ -1,5 +1,5 @@
import { baseApiUrl } from '@/api';
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
export const HOME_URL = '/home';
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
@@ -1,4 +1,5 @@
export * from './api';
export * from './components';
export * from './conf';
export * from './hooks';
export * from './utils';
@@ -0,0 +1,35 @@
import { gotoLogin } from '@/features/auth';
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry';
const isRetryAllowed = () => {
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
if (!lastRetryDate) {
return true;
}
const now = new Date();
return now.getTime() > Number(lastRetryDate);
};
const setNextRetryTime = (retryIntervalInSeconds: number) => {
const now = new Date();
const nextRetryTime = now.getTime() + retryIntervalInSeconds * 1000;
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
};
const initiateSilentLogin = () => {
const currentPath = window.location.pathname;
gotoLogin(currentPath, true);
};
export const canAttemptSilentLogin = () => {
return isRetryAllowed();
};
export const attemptSilentLogin = (retryIntervalInSeconds: number) => {
if (!isRetryAllowed()) {
return;
}
setNextRetryTime(retryIntervalInSeconds);
initiateSilentLogin();
};
@@ -1,27 +1,12 @@
import { terminateCrispSession } from '@/services/Crisp';
import { LOGIN_URL, LOGOUT_URL, PATH_AUTH_LOCAL_STORAGE } from './conf';
import { LOGIN_URL, LOGOUT_URL } from './conf';
export const getAuthUrl = () => {
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
return path_auth;
}
};
export const setAuthUrl = () => {
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
}
};
export const gotoLogin = (withRedirect = true) => {
if (withRedirect) {
setAuthUrl();
}
window.location.replace(LOGIN_URL);
export const gotoLogin = (returnTo = '/', isSilent = false) => {
const authenticateUrl =
LOGIN_URL +
`?silent=${encodeURIComponent(isSilent)}&returnTo=${window.location.origin + returnTo}`;
window.location.replace(authenticateUrl);
};
export const gotoLogout = () => {
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { Doc } from '@/docs/doc-management';
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import { useUploadFile } from '../hook';
@@ -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,
},
}),
@@ -48,7 +49,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const readOnly = !doc.abilities.partial_update;
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
useSaveDoc(doc.id, provider.document, !readOnly);
const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage;
@@ -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,
@@ -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 (
@@ -25,7 +25,6 @@ interface DocEditorProps {
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
@@ -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>
);
};
@@ -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',
});
@@ -1 +1,2 @@
export * from './CalloutBlock';
export * from './DividerBlock';
@@ -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
@@ -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;
}
@@ -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,
}),
],
});
};
@@ -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>
);
};
@@ -21,6 +21,7 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
const blob = await exporter.resolveFile(block.props.url);
let pngConverted: string | undefined;
let dimensions: { width: number; height: number } | undefined;
let previewWidth = block.props.previewWidth || undefined;
if (!blob.type.includes('image')) {
return [];
@@ -28,7 +29,9 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
if (blob.type.includes('svg')) {
const svgText = await blob.text();
pngConverted = await convertSvgToPng(svgText, block.props.previewWidth);
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, previewWidth);
const img = new Image();
img.src = pngConverted;
await new Promise((resolve) => {
@@ -47,8 +50,7 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
const { width, height } = dimensions;
let previewWidth = block.props.previewWidth;
if (previewWidth > MAX_WIDTH) {
if (previewWidth && previewWidth > MAX_WIDTH) {
previewWidth = MAX_WIDTH;
}
@@ -69,8 +71,8 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
}
: undefined,
transformation: {
width: previewWidth,
height: (previewWidth / width) * height,
width: previewWidth || width,
height: ((previewWidth || width) / width) * height,
},
}),
],
@@ -12,6 +12,7 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
async (block, exporter) => {
const blob = await exporter.resolveFile(block.props.url);
let pngConverted: string | undefined;
let width = block.props.previewWidth || undefined;
if (!blob.type.includes('image')) {
return <View wrap={false} />;
@@ -19,7 +20,9 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
if (blob.type.includes('svg')) {
const svgText = await blob.text();
pngConverted = await convertSvgToPng(svgText, block.props.previewWidth);
const FALLBACK_SIZE = 536;
width = width || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, width);
}
return (
@@ -27,7 +30,7 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
<Image
src={pngConverted || blob}
style={{
width: block.props.previewWidth * PIXELS_PER_POINT,
width: width ? width * PIXELS_PER_POINT : undefined,
maxWidth: '100%',
}}
/>
@@ -1,3 +1,5 @@
export * from './calloutDocx';
export * from './calloutPDF';
export * from './dividerDocx';
export * from './dividerPDF';
export * from './headingPDF';
@@ -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';
@@ -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,
@@ -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,
@@ -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
@@ -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();
});
@@ -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();
});
@@ -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,
},
);
});

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