Compare commits

..

19 Commits

Author SHA1 Message Date
Manuel Raynaud
309710ca29 fixup! (back) add endpoint checking media status 2025-05-22 06:41:51 +02:00
Manuel Raynaud
7fe3287a14 ♻️(back) return the media-check url on the attachment_upload response
We want to have the media-check url returned on the attachment-upload
response instead of the media url directly. The front will know the
endpoint to use to check the media status.
2025-05-21 15:48:26 +02:00
Manuel Raynaud
9462690f14 (back) add endpoint checking media status
With the usage of a malware detection system, we need a way to know the
file status. The front will use it to display a loader while the analyse
is not ended.
2025-05-21 15:48:26 +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
67 changed files with 2843 additions and 2124 deletions

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 -->

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! -->

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! 👌

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

View File

@@ -91,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
@@ -186,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]

View File

@@ -8,18 +8,24 @@ and this project adheres to
## [Unreleased]
## Added
### Added
- ✨(back) allow theme customnization using a configuration file #948
- ✨(back) add endpoint checking media status
- ✨(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
### 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

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.

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

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">

BIN
docs/assets/banner-docs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@@ -39,7 +39,7 @@ These are the environment 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 address 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 | [] |
@@ -54,12 +54,13 @@ These are the environment variables you can set for the `impress-backend` contai
| 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 | Authorization 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 | |
@@ -73,7 +74,7 @@ These are the environment 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 duplicate 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 |
@@ -106,8 +107,36 @@ These are the environment variables you can set for the `impress-backend` contai
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 | MIT licence does not include the export feature | true |
| 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.

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"

0
secu-audit.md Normal file
View File

View File

@@ -1,11 +1,9 @@
"""Permission handlers for the impress core app."""
from django.conf import settings
from django.core import exceptions
from django.db.models import Q
from django.http import Http404
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import permissions
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
@@ -136,38 +134,3 @@ class DocumentAccessPermission(AccessPermission):
raise Http404
return has_permission
class ResourceServerClientPermission(permissions.BasePermission):
"""
Permission class for resource server views.
This provides a way to open the resource server views to a limited set of
Service Providers.
Note: we might add a more complex permission system in the future, based on
the Service Provider ID and the requested scopes.
"""
def has_permission(self, request, view):
"""
Check if the user is authenticated and the token introspection
provides an authorized Service Provider.
"""
if not isinstance(
request.successful_authenticator, ResourceServerAuthentication
):
# Not a resource server request
return True
# Check if the user is authenticated
if not request.user.is_authenticated:
return False
if view.action not in view.resource_server_actions:
return False
# When used as a resource server, the request has a token audience
return (
request.resource_server_token_audience in settings.OIDC_RS_ALLOWED_AUDIENCES
)

View File

@@ -4,7 +4,7 @@
import json
import logging
import uuid
from urllib.parse import unquote, urlparse
from urllib.parse import unquote, urlencode, urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
@@ -18,6 +18,7 @@ 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.urls import reverse
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -25,7 +26,6 @@ import requests
import rest_framework as drf
from botocore.exceptions import ClientError
from lasuite.malware_detection import malware_detection
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
@@ -432,7 +432,6 @@ class DocumentViewSet(
pagination_class = Pagination
permission_classes = [
permissions.DocumentAccessPermission,
permissions.ResourceServerClientPermission,
]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
@@ -442,22 +441,6 @@ class DocumentViewSet(
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
resource_server_actions = {
"list",
"retrieve",
"create_for_owner",
}
def get_authenticators(self):
"""Allow resource server authentication for very specific actions."""
authenticators = super().get_authenticators()
# self.action does not exist yet
action = self.action_map[self.request.method.lower()]
if action in self.resource_server_actions:
authenticators.append(ResourceServerAuthentication())
return authenticators
def annotate_is_favorite(self, queryset):
"""
@@ -689,7 +672,7 @@ class DocumentViewSet(
authentication_classes=[authentication.ServerToServerAuthentication],
detail=False,
methods=["post"],
permission_classes=[permissions.IsAuthenticated],
permission_classes=[],
url_path="create-for-owner",
)
@transaction.atomic
@@ -1211,8 +1194,16 @@ class DocumentViewSet(
malware_detection.analyse_file(key, document_id=document.id)
url = reverse(
"documents-media-check",
kwargs={"pk": document.id},
)
parameters = urlencode({"key": key})
return drf.response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
{
"file": f"{url:s}?{parameters:s}",
},
status=drf.status.HTTP_201_CREATED,
)
@@ -1297,7 +1288,10 @@ class DocumentViewSet(
# 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)
try:
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
except ClientError as err:
raise drf.exceptions.PermissionDenied() from err
metadata = head_resp.get("Metadata", {})
# In order to be compatible with existing upload without `status` metadata,
# we consider them as ready.
@@ -1312,6 +1306,49 @@ class DocumentViewSet(
return drf.response.Response("authorized", headers=request.headers, status=200)
@drf.decorators.action(detail=True, methods=["get"], url_path="media-check")
def media_check(self, request, *args, **kwargs):
"""
Check if the media is ready to be served.
"""
document = self.get_object()
key = request.query_params.get("key")
if not key:
return drf.response.Response(
{"detail": "Missing 'key' query parameter"},
status=drf.status.HTTP_400_BAD_REQUEST,
)
if key not in document.attachments:
return drf.response.Response(
{"detail": "Attachment missing"},
status=drf.status.HTTP_404_NOT_FOUND,
)
# Check if the attachment is ready
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
try:
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
except ClientError:
return drf.response.Response(
{"detail": "Media not found"},
status=drf.status.HTTP_404_NOT_FOUND,
)
metadata = head_resp.get("Metadata", {})
body = {
"status": metadata.get("status", enums.DocumentAttachmentStatus.PROCESSING),
}
if metadata.get("status") == enums.DocumentAttachmentStatus.READY:
body = {
"status": enums.DocumentAttachmentStatus.READY,
"file": f"{settings.MEDIA_URL:s}{key:s}",
}
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],

View File

@@ -6,15 +6,6 @@ from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class AuthenticatedServer:
"""
Simple class to represent an authenticated server to be used along the
IsAuthenticated permission.
"""
is_authenticated = True
class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
@@ -48,16 +39,13 @@ class ServerToServerAuthentication(BaseAuthentication):
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
# Do not raise here to leave the door open for other authentication methods
return None
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
# Do not raise here to leave the door open for other authentication methods
return None
raise AuthenticationFailed("Invalid server-to-server token.")
# Authentication is successful
return AuthenticatedServer(), token
# Authentication is successful, but no user is authenticated
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""

View File

@@ -835,6 +835,7 @@ class Document(MP_Node, BaseModel):
"ai_transform": ai_access,
"ai_translate": ai_access,
"attachment_upload": can_update,
"media_check": can_get,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,

View File

@@ -5,6 +5,7 @@ Test file uploads API endpoint for users in impress's core app.
import re
import uuid
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -66,8 +67,12 @@ def test_api_documents_attachment_upload_anonymous_success():
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
file_path = response.json()["file"]
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
@@ -148,8 +153,13 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1)
mock_analyse_file.assert_called_once_with(
@@ -224,8 +234,12 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1)
@@ -320,8 +334,13 @@ def test_api_documents_attachment_upload_fix_extension(
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.{extension:s}")
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.{extension:s}")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1)
@@ -386,8 +405,12 @@ def test_api_documents_attachment_upload_unsafe():
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.exe")
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.exe")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1)
@@ -410,5 +433,9 @@ def test_api_documents_attachment_upload_unsafe():
"is_unsafe": "true",
"status": "processing",
}
assert file_head["ContentType"] == "application/octet-stream"
# 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"'

View File

@@ -0,0 +1,244 @@
"""Test the "media_check" endpoint."""
from io import BytesIO
from uuid import uuid4
from django.core.files.storage import default_storage
import pytest
from rest_framework.test import APIClient
from core import factories
from core.enums import DocumentAttachmentStatus
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_media_check_unknown_document():
"""
The "media_check" endpoint should return a 404 error if the document does not exist.
"""
client = APIClient()
response = client.get(f"/api/v1.0/documents/{uuid4()!s}media-check/")
assert response.status_code == 404
def test_api_documents_media_check_missing_key():
"""
The "media_check" endpoint should return a 404 error if the key is missing.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user=user)
document = factories.DocumentFactory(users=[user])
response = client.get(f"/api/v1.0/documents/{document.id!s}/media-check/")
assert response.status_code == 400
assert response.json() == {"detail": "Missing 'key' query parameter"}
def test_api_documents_media_check_key_parameter_not_related_to_document():
"""
The "media_check" endpoint should return a 404 error if the key is not related to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user=user)
document = factories.DocumentFactory(users=[user])
response = client.get(
f"/api/v1.0/documents/{document.id!s}/media-check/",
{"key": f"{document.id!s}/attachments/unknown.jpg"},
)
assert response.status_code == 404
assert response.json() == {"detail": "Attachment missing"}
def test_api_documents_media_check_anonymous_public_document():
"""
The "media_check" endpoint should return a 200 status code if the document is public.
"""
document = factories.DocumentFactory(link_reach="public")
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},
)
document.attachments = [key]
document.save(update_fields=["attachments"])
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
)
assert response.status_code == 200
assert response.json() == {"status": DocumentAttachmentStatus.PROCESSING}
def test_api_documents_media_check_anonymous_public_document_ready():
"""
The "media_check" endpoint should return a 200 status code if the document is public.
"""
document = factories.DocumentFactory(link_reach="public")
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.READY},
)
document.attachments = [key]
document.save(update_fields=["attachments"])
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
)
assert response.status_code == 200
assert response.json() == {
"status": DocumentAttachmentStatus.READY,
"file": f"/media/{key:s}",
}
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
def test_api_documents_media_check_anonymous_non_public_document(link_reach):
"""
The "media_check" endpoint should return a 403 error if the document is not public.
"""
document = factories.DocumentFactory(link_reach=link_reach)
client = APIClient()
response = client.get(f"/api/v1.0/documents/{document.id!s}/media-check/")
assert response.status_code == 401
def test_api_documents_media_check_connected_document():
"""
The "media_check" endpoint should return a 200 status code for a user connected
checking for a document with link_reach authenticated.
"""
document = factories.DocumentFactory(link_reach="authenticated")
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.READY},
)
document.attachments = [key]
document.save(update_fields=["attachments"])
user = factories.UserFactory()
client = APIClient()
client.force_login(user=user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
)
assert response.status_code == 200
assert response.json() == {
"status": DocumentAttachmentStatus.READY,
"file": f"/media/{key:s}",
}
def test_api_documents_media_check_connected_document_media_not_related():
"""
The "media_check" endpoint should return a 404 error if the key is not related to the document.
"""
document = factories.DocumentFactory(link_reach="authenticated")
filename = f"{uuid4()!s}.jpg"
key = f"{document.id!s}/attachments/{filename:s}"
user = factories.UserFactory()
client = APIClient()
client.force_login(user=user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
)
assert response.status_code == 404
assert response.json() == {"detail": "Attachment missing"}
def test_api_documents_media_check_media_missing_on_storage():
"""
The "media_check" endpoint should return a 404 error if the media is missing on storage.
"""
document = factories.DocumentFactory(link_reach="authenticated")
filename = f"{uuid4()!s}.jpg"
key = f"{document.id!s}/attachments/{filename:s}"
document.attachments = [key]
document.save(update_fields=["attachments"])
user = factories.UserFactory()
client = APIClient()
client.force_login(user=user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
)
assert response.status_code == 404
assert response.json() == {"detail": "Media not found"}
@pytest.mark.parametrize("via", VIA)
def test_api_documents_media_check_restricted_document(via, mock_user_teams):
"""
The "media_check" endpoint should return a 200 status code if the document is restricted and
the user has access to it.
"""
document = factories.DocumentFactory(link_reach="restricted")
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.READY},
)
document.attachments = [key]
document.save(update_fields=["attachments"])
user = factories.UserFactory()
client = APIClient()
client.force_login(user=user)
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
)
assert response.status_code == 200
assert response.json() == {
"status": DocumentAttachmentStatus.READY,
"file": f"/media/{key:s}",
}

View File

@@ -48,6 +48,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": document.link_role == "editor",
"restore": False,
@@ -111,6 +112,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links),
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
@@ -210,6 +212,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": document.link_role == "editor",
"restore": False,
@@ -279,8 +282,9 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"invite_owner": False,
"link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links),
"move": False,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
@@ -460,6 +464,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"link_configuration": access.role in ["administrator", "owner"],
"link_select_options": models.LinkReachChoices.get_select_options(links),
"media_auth": True,
"media_check": True,
"move": access.role in ["administrator", "owner"],
"partial_update": access.role != "reader",
"restore": access.role == "owner",

View File

@@ -91,6 +91,7 @@ def test_api_documents_trashbin_format():
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": False, # Can't move a deleted document
"partial_update": True,
"restore": True,

View File

@@ -165,6 +165,7 @@ def test_models_documents_get_abilities_forbidden(
"favorite": False,
"invite_owner": False,
"media_auth": False,
"media_check": False,
"move": False,
"link_configuration": False,
"link_select_options": {
@@ -231,6 +232,7 @@ def test_models_documents_get_abilities_reader(
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": False,
"restore": False,
@@ -293,6 +295,7 @@ def test_models_documents_get_abilities_editor(
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": True,
"restore": False,
@@ -344,6 +347,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": True,
"partial_update": True,
"restore": True,
@@ -392,6 +396,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": True,
"partial_update": True,
"restore": False,
@@ -443,6 +448,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": True,
"restore": False,
@@ -501,6 +507,7 @@ def test_models_documents_get_abilities_reader_user(
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": access_from_link,
"restore": False,
@@ -557,6 +564,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"restricted": ["reader", "editor"],
},
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": False,
"restore": False,

View File

@@ -4,7 +4,6 @@ from django.conf import settings
from django.urls import include, path, re_path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
@@ -45,7 +44,6 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*resource_server_urls,
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(document_related_router.urls),

View File

@@ -327,6 +327,7 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PARSER_CLASSES": [
@@ -461,7 +462,9 @@ 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_CREATE_USER = values.BooleanValue(
@@ -585,65 +588,6 @@ class Base(Configuration):
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
# OIDC - Docs as a resource server
OIDC_OP_URL = values.Value(
default=None, environ_name="OIDC_OP_URL", environ_prefix=None
)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_VERIFY_SSL = values.BooleanValue(
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
)
OIDC_TIMEOUT = values.IntegerValue(
default=3, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.ResourceServerBackend"
OIDC_RS_AUDIENCE_CLAIM = values.Value( # The claim used to identify the audience
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_PRIVATE_KEY_STR = values.Value(
default=None,
environ_name="OIDC_RS_PRIVATE_KEY_STR",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA",
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
default="RSA-OAEP",
environ_name="OIDC_RS_ENCRYPTION_ALGO",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
default="A256GCM",
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
environ_prefix=None,
)
OIDC_RS_CLIENT_ID = values.Value(
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = values.Value(
None,
environ_name="OIDC_RS_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_RS_SIGNING_ALGO = values.Value(
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
[], environ_name="OIDC_RS_SCOPES", environ_prefix=None
)
OIDC_RS_ALLOWED_AUDIENCES = values.ListValue(
default=[],
environ_name="OIDC_RS_ALLOWED_AUDIENCES",
environ_prefix=None,
)
# AI service
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None

View File

@@ -26,7 +26,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.13",
"boto3==1.38.18",
"Brotli==1.1.0",
"celery[redis]==5.5.2",
"django-configurations==2.5.1",
@@ -51,9 +51,9 @@ dependencies = [
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.78.1",
"psycopg[binary]==3.2.8",
"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",
@@ -85,8 +85,8 @@ dev = [
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.9",
"types-requests==2.32.0.20250328",
"ruff==0.11.10",
"types-requests==2.32.0.20250515",
]
[tool.setuptools]

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/
@@ -45,7 +50,19 @@ 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

View File

@@ -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

View File

@@ -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(

View File

@@ -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: '',
@@ -453,6 +455,55 @@ test.describe('Doc Editor', () => {
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);

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,
...{

View File

@@ -15,43 +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",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"i18next": "25.1.1",
"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": "*",
@@ -59,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",
@@ -82,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"
}
}

View File

@@ -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);
}

View File

@@ -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 },
},

View File

@@ -5,6 +5,7 @@ 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';
@@ -43,7 +44,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
*/
if (!authenticated && !pathAllowed) {
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
void replace('/home');
void replace(HOME_URL);
} else {
gotoLogin();
}
@@ -57,7 +58,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">

View File

@@ -1,5 +1,6 @@
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/`;
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';

View File

@@ -1,4 +1,5 @@
export * from './api';
export * from './components';
export * from './conf';
export * from './hooks';
export * from './utils';

View File

@@ -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';
@@ -49,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;

View File

@@ -25,7 +25,6 @@ interface DocEditorProps {
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();

View File

@@ -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,
},
}),
],

View File

@@ -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%',
}}
/>

View File

@@ -0,0 +1,113 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export const AlertNetwork = () => {
const { t } = useTranslation();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<Box>
<Box
$direction="row"
$justify="space-between"
$width="100%"
$background={colorsTokens['warning-100']}
$radius={spacingsTokens['3xs']}
$padding="xs"
$flex={1}
$align="center"
$gap={spacingsTokens['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--warning-300);
`}
>
<Box $direction="row" $gap={spacingsTokens['2xs']}>
<Icon iconName="mobiledata_off" $theme="warning" $variation="600" />
<Text $theme="warning" $variation="600" $weight={500}>
{t('Your network do not allow you to edit')}
</Text>
</Box>
<BoxButton
$direction="row"
$gap={spacingsTokens['3xs']}
$align="center"
onClick={() => setIsModalOpen(true)}
>
<Icon
iconName="info"
$theme="warning"
$variation="600"
$size="16px"
$weight="500"
$margin={{ top: 'auto' }}
/>
<Text $theme="warning" $variation="600" $weight="500" $size="xs">
{t('Know more')}
</Text>
</BoxButton>
</Box>
</Box>
{isModalOpen && (
<AlertNetworkModal onClose={() => setIsModalOpen(false)} />
)}
</>
);
};
interface AlertNetworkModalProps {
onClose: () => void;
}
export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
return (
<Modal
isOpen
closeOnClickOutside
onClose={() => onClose()}
rightActions={
<>
<Button aria-label={t('OK')} onClick={onClose}>
{t('OK')}
</Button>
</>
}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
{t("Why can't I edit?")}
</Text>
}
>
<Box
aria-label={t('Content modal to explain why the user cannot edit')}
className="--docs--modal-alert-network"
$margin={{ top: 'xs' }}
>
<Text $size="sm" $variation="600">
{t(
'The network configuration of your workstation or internet connection does not allow editing shared documents.',
)}
</Text>
<Text $size="sm" $variation="600" $margin={{ top: 'xs' }}>
{t(
'Docs use WebSockets to enable real-time editing. These communication channels allow instant and bidirectional exchanges between your browser and our servers. To access collaborative editing, please contact your IT department to enable WebSockets.',
)}
</Text>
</Box>
</Modal>
);
};

View File

@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export const AlertPublic = ({ isPublicDoc }: { isPublicDoc: boolean }) => {
const { t } = useTranslation();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
return (
<Box
aria-label={t('Public document')}
$color={colorsTokens['primary-800']}
$background={colorsTokens['primary-050']}
$radius={spacingsTokens['3xs']}
$direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacingsTokens['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
`}
>
<Icon
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName={isPublicDoc ? 'public' : 'vpn_lock'}
/>
<Text $theme="primary" $variation="800" $weight="500">
{isPublicDoc
? t('Public document')
: t('Document accessible to any connected person')}
</Text>
</Box>
);
};

View File

@@ -1,17 +1,20 @@
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, HorizontalSeparator, Icon, Text } from '@/components';
import { Box, HorizontalSeparator, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
LinkReach,
Role,
currentDocRole,
useIsCollaborativeEditable,
useTrans,
} from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { AlertNetwork } from './AlertNetwork';
import { AlertPublic } from './AlertPublic';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
@@ -20,51 +23,26 @@ interface DocHeaderProps {
}
export const DocHeader = ({ doc }: DocHeaderProps) => {
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { transRole } = useTrans();
const { isEditable } = useIsCollaborativeEditable(doc);
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const { transRole } = useTrans();
return (
<>
<Box
$width="100%"
$padding={{ top: isDesktop ? '4xl' : 'md' }}
$padding={{ top: isDesktop ? '50px' : 'md' }}
$gap={spacingsTokens['base']}
aria-label={t('It is the card information about the document.')}
className="--docs--doc-header"
>
{!isEditable && <AlertNetwork />}
{(docIsPublic || docIsAuth) && (
<Box
aria-label={t('Public document')}
$color={colorsTokens['primary-800']}
$background={colorsTokens['primary-050']}
$radius={spacingsTokens['3xs']}
$direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacingsTokens['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
`}
>
<Icon
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName={docIsPublic ? 'public' : 'vpn_lock'}
/>
<Text $theme="primary" $variation="800">
{docIsPublic
? t('Public document')
: t('Document accessible to any connected person')}
</Text>
</Box>
<AlertPublic isPublicDoc={docIsPublic} />
)}
<Box
$direction="row"
@@ -86,8 +64,18 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<Box $direction="row">
{isDesktop && (
<>
<Text $variation="600" $size="s" $weight="bold">
{transRole(currentDocRole(doc.abilities))}&nbsp;·&nbsp;
<Text
$variation="600"
$size="s"
$weight="bold"
$theme={isEditable ? 'greyscale' : 'warning'}
>
{transRole(
isEditable
? currentDocRole(doc.abilities)
: Role.READER,
)}
&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{t('Last update: {{update}}', {

View File

@@ -1,3 +1,4 @@
export * from './useCollaboration';
export * from './useTrans';
export * from './useCopyDocLink';
export * from './useIsCollaborativeEditable';
export * from './useTrans';

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { useIsOffline } from '@/features/service-worker';
import { useProviderStore } from '../stores';
import { Doc, LinkReach } from '../types';
export const useIsCollaborativeEditable = (doc: Doc) => {
const { isConnected } = useProviderStore();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const docHasMember = doc.nb_accesses_direct > 1;
const isShared = docIsPublic || docIsAuth || docHasMember;
const [isEditable, setIsEditable] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const { isOffline } = useIsOffline();
/**
* Connection can take a few seconds
*/
useEffect(() => {
const _isEditable = isConnected || !isShared || isOffline;
setIsLoading(true);
if (_isEditable) {
setIsEditable(true);
setIsLoading(false);
return;
}
const timer = setTimeout(() => {
setIsEditable(false);
setIsLoading(false);
}, 5000);
return () => clearTimeout(timer);
}, [isConnected, isOffline, isShared]);
return {
isEditable,
isLoading,
};
};

View File

@@ -1,4 +1,4 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -12,10 +12,12 @@ export interface UseCollaborationStore {
) => HocuspocusProvider;
destroyProvider: () => void;
provider: HocuspocusProvider | undefined;
isConnected: boolean;
}
const defaultValues = {
provider: undefined,
isConnected: false,
};
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
@@ -33,6 +35,11 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
url: wsUrl,
name: storeId,
document: doc,
onStatus: ({ status }) => {
set({
isConnected: status === WebSocketStatus.Connected,
});
},
});
set({

View File

@@ -4,8 +4,8 @@
import '@testing-library/jest-dom';
import { ApiPlugin } from '../ApiPlugin';
import { RequestSerializer } from '../RequestSerializer';
import { ApiPlugin } from '../plugins/ApiPlugin';
const mockedGet = jest.fn().mockResolvedValue({});
const mockedGetAllKeys = jest.fn().mockResolvedValue([]);

View File

@@ -0,0 +1,65 @@
/**
* @jest-environment node
*/
import '@testing-library/jest-dom';
import { MESSAGE_TYPE } from '../conf';
import { OfflinePlugin } from '../plugins/OfflinePlugin';
const mockServiceWorkerScope = {
clients: {
matchAll: jest.fn().mockResolvedValue([]),
},
} as unknown as ServiceWorkerGlobalScope;
(global as any).self = {
...global,
clients: mockServiceWorkerScope.clients,
} as unknown as ServiceWorkerGlobalScope;
describe('OfflinePlugin', () => {
afterEach(() => jest.clearAllMocks());
it(`calls fetchDidSucceed`, async () => {
const apiPlugin = new OfflinePlugin();
const postMessageSpy = jest.spyOn(apiPlugin, 'postMessage');
await apiPlugin.fetchDidSucceed?.({
response: new Response(),
} as any);
expect(postMessageSpy).toHaveBeenCalledWith(false, 'fetchDidSucceed');
});
it(`calls fetchDidFail`, async () => {
const apiPlugin = new OfflinePlugin();
const postMessageSpy = jest.spyOn(apiPlugin, 'postMessage');
await apiPlugin.fetchDidFail?.({} as any);
expect(postMessageSpy).toHaveBeenCalledWith(true, 'fetchDidFail');
});
it(`calls postMessage`, async () => {
const apiPlugin = new OfflinePlugin();
const mockClients = [
{ postMessage: jest.fn() },
{ postMessage: jest.fn() },
];
mockServiceWorkerScope.clients.matchAll = jest
.fn()
.mockResolvedValue(mockClients);
await apiPlugin.postMessage(false, 'testMessage');
for (const client of mockClients) {
expect(client.postMessage).toHaveBeenCalledWith({
type: MESSAGE_TYPE.OFFLINE,
value: false,
message: 'testMessage',
});
}
});
});

View File

@@ -0,0 +1,63 @@
import '@testing-library/jest-dom';
import { act, renderHook } from '@testing-library/react';
import { MESSAGE_TYPE } from '../conf';
import { useIsOffline, useOffline } from '../hooks/useOffline';
const mockAddEventListener = jest.fn();
const mockRemoveEventListener = jest.fn();
Object.defineProperty(navigator, 'serviceWorker', {
value: {
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
},
writable: true,
});
describe('useOffline', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should set isOffline to true when receiving an offline message', () => {
useIsOffline.setState({ isOffline: false });
const { result } = renderHook(() => useIsOffline());
renderHook(() => useOffline());
act(() => {
const messageEvent = {
data: {
type: MESSAGE_TYPE.OFFLINE,
value: true,
message: 'Offline',
},
};
mockAddEventListener.mock.calls[0][1](messageEvent);
});
expect(result.current.isOffline).toBe(true);
});
it('should set isOffline to false when receiving an online message', () => {
useIsOffline.setState({ isOffline: false });
const { result } = renderHook(() => useIsOffline());
renderHook(() => useOffline());
act(() => {
const messageEvent = {
data: {
type: MESSAGE_TYPE.OFFLINE,
value: false,
message: 'Online',
},
};
mockAddEventListener.mock.calls[0][1](messageEvent);
});
expect(result.current.isOffline).toBe(false);
});
});

View File

@@ -26,6 +26,7 @@ describe('useSWRegister', () => {
value: {
register: registerSpy,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
},
writable: true,
});

View File

@@ -3,14 +3,15 @@ import pkg from '@/../package.json';
export const SW_DEV_URL = [
'http://localhost:3000',
'https://impress.127.0.0.1.nip.io',
'https://impress-staging.beta.numerique.gouv.fr',
];
export const SW_DEV_API = 'http://localhost:8071';
export const SW_VERSION = `v-${process.env.NEXT_PUBLIC_BUILD_ID}`;
export const DAYS_EXP = 5;
export const getCacheNameVersion = (cacheName: string) =>
`${pkg.name}-${cacheName}-${SW_VERSION}`;
export const MESSAGE_TYPE = {
OFFLINE: 'OFFLINE',
};

View File

@@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { create } from 'zustand';
import { MESSAGE_TYPE } from '../conf';
interface OfflineMessageData {
type: string;
value: boolean;
message: string;
}
interface IsOfflineState {
isOffline: boolean;
setIsOffline: (value: boolean) => void;
}
export const useIsOffline = create<IsOfflineState>((set) => ({
isOffline: typeof navigator !== 'undefined' && !navigator.onLine,
setIsOffline: (value: boolean) => set({ isOffline: value }),
}));
export const useOffline = () => {
const { setIsOffline } = useIsOffline();
useEffect(() => {
const handleMessage = (event: MessageEvent<OfflineMessageData>) => {
if (event.data?.type === MESSAGE_TYPE.OFFLINE) {
setIsOffline(event.data.value);
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => {
navigator.serviceWorker?.removeEventListener('message', handleMessage);
};
}, [setIsOffline]);
};

View File

@@ -30,11 +30,22 @@ export const useSWRegister = () => {
});
const currentController = navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener('controllerchange', () => {
const onControllerChange = () => {
if (currentController) {
window.location.reload();
}
});
};
navigator.serviceWorker.addEventListener(
'controllerchange',
onControllerChange,
);
return () => {
navigator.serviceWorker.removeEventListener(
'controllerchange',
onControllerChange,
);
};
}
}, []);
};

View File

@@ -1 +1,2 @@
export * from './hooks/useOffline';
export * from './hooks/useSWRegister';

View File

@@ -3,9 +3,9 @@ import { WorkboxPlugin } from 'workbox-core';
import { Doc, DocsResponse } from '@/docs/doc-management';
import { LinkReach, LinkRole } from '@/docs/doc-management/types';
import { DBRequest, DocsDB } from './DocsDB';
import { RequestSerializer } from './RequestSerializer';
import { SyncManager } from './SyncManager';
import { DBRequest, DocsDB } from '../DocsDB';
import { RequestSerializer } from '../RequestSerializer';
import { SyncManager } from '../SyncManager';
interface OptionsReadonly {
tableName: 'doc-list' | 'doc-item';

View File

@@ -0,0 +1,36 @@
import { WorkboxPlugin } from 'workbox-core';
import { MESSAGE_TYPE } from '../conf';
declare const self: ServiceWorkerGlobalScope;
export class OfflinePlugin implements WorkboxPlugin {
constructor() {}
postMessage = async (value: boolean, message: string) => {
const allClients = await self.clients.matchAll({
includeUncontrolled: true,
});
for (const client of allClients) {
client.postMessage({
type: MESSAGE_TYPE.OFFLINE,
value,
message,
});
}
};
/**
* Means that the fetch failed (500 is not failed), so often it is a network error.
*/
fetchDidFail: WorkboxPlugin['fetchDidFail'] = async () => {
void this.postMessage(true, 'fetchDidFail');
return Promise.resolve();
};
fetchDidSucceed: WorkboxPlugin['fetchDidSucceed'] = async ({ response }) => {
void this.postMessage(false, 'fetchDidSucceed');
return Promise.resolve(response);
};
}

View File

@@ -3,10 +3,11 @@ import { ExpirationPlugin } from 'workbox-expiration';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, NetworkOnly } from 'workbox-strategies';
import { ApiPlugin } from './ApiPlugin';
import { DocsDB } from './DocsDB';
import { SyncManager } from './SyncManager';
import { DAYS_EXP, SW_DEV_API, getCacheNameVersion } from './conf';
import { ApiPlugin } from './plugins/ApiPlugin';
import { OfflinePlugin } from './plugins/OfflinePlugin';
declare const self: ServiceWorkerGlobalScope;
@@ -37,6 +38,7 @@ registerRoute(
type: 'list',
syncManager,
}),
new OfflinePlugin(),
],
}),
'GET',
@@ -52,6 +54,7 @@ registerRoute(
type: 'item',
syncManager,
}),
new OfflinePlugin(),
],
}),
'GET',
@@ -66,6 +69,7 @@ registerRoute(
type: 'update',
syncManager,
}),
new OfflinePlugin(),
],
}),
'PATCH',
@@ -79,6 +83,7 @@ registerRoute(
type: 'create',
syncManager,
}),
new OfflinePlugin(),
],
}),
'POST',
@@ -93,6 +98,7 @@ registerRoute(
type: 'delete',
syncManager,
}),
new OfflinePlugin(),
],
}),
'DELETE',
@@ -111,6 +117,7 @@ registerRoute(
type: 'synch',
syncManager,
}),
new OfflinePlugin(),
],
}),
'GET',

View File

@@ -19,8 +19,9 @@ import {
} from 'workbox-strategies';
// eslint-disable-next-line import/order
import { ApiPlugin } from './ApiPlugin';
import { DAYS_EXP, SW_DEV_URL, SW_VERSION, getCacheNameVersion } from './conf';
import { ApiPlugin } from './plugins/ApiPlugin';
import { OfflinePlugin } from './plugins/OfflinePlugin';
import { isApiUrl } from './service-worker-api';
// eslint-disable-next-line import/order
@@ -154,6 +155,7 @@ registerRoute(
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP }),
new OfflinePlugin(),
],
}),
);
@@ -170,6 +172,7 @@ registerRoute(
new ExpirationPlugin({
maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP,
}),
new OfflinePlugin(),
],
}),
'GET',
@@ -236,6 +239,20 @@ registerRoute(
}),
);
/**
* External urls post cache strategy
* It is interesting to intercept the request
* to have a fine grain control about if the user is
* online or offline
*/
registerRoute(
({ url }) => !url.href.includes(self.location.origin) && !isApiUrl(url.href),
new NetworkOnly({
plugins: [new OfflinePlugin()],
}),
'POST',
);
/**
* Cache all other files
*/

View File

@@ -3,7 +3,7 @@ import Head from 'next/head';
import { useTranslation } from 'react-i18next';
import { AppProvider } from '@/core/';
import { useSWRegister } from '@/features/service-worker/';
import { useOffline, useSWRegister } from '@/features/service-worker/';
import '@/i18n/initI18n';
import { NextPageWithLayout } from '@/types/next';
@@ -15,6 +15,7 @@ type AppPropsWithLayout = AppProps & {
export default function App({ Component, pageProps }: AppPropsWithLayout) {
useSWRegister();
useOffline();
const getLayout = Component.getLayout ?? ((page) => page);
const { t } = useTranslation();

View File

@@ -126,7 +126,7 @@ const DocPage = ({ id }: DocProps) => {
causes={error.cause}
icon={
error.status === 502 ? (
<Icon iconName="wifi_off" $theme="danger" />
<Icon iconName="wifi_off" $theme="danger" $variation="600" />
) : undefined
}
/>

View File

@@ -1,8 +1,10 @@
import { HomeContent } from '@/features/home';
import { NextPageWithLayout } from '@/types/next';
import { useRouter } from 'next/router';
const Page: NextPageWithLayout = () => {
return <HomeContent />;
import { HOME_URL } from '@/features/auth';
const Page = () => {
const { replace } = useRouter();
void replace(HOME_URL);
};
export default Page;

View File

@@ -28,16 +28,15 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@types/node": "22.15.12",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@types/node": "22.15.19",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"@typescript-eslint/eslint-plugin": "8.32.1",
"@typescript-eslint/parser": "8.32.1",
"eslint": "8.57.0",
"prosemirror-model": "1.25.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "5.8.3",
"yjs": "13.6.26"
"yjs": "13.6.27"
}
}

View File

@@ -6,19 +6,19 @@
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "15.3.1",
"@next/eslint-plugin-next": "15.3.2",
"@tanstack/eslint-plugin-query": "5.74.7",
"@typescript-eslint/eslint-plugin": "*",
"@typescript-eslint/parser": "*",
"eslint": "*",
"eslint-config-next": "15.3.1",
"eslint-config-prettier": "10.1.2",
"eslint-config-next": "15.3.2",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.11.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-prettier": "5.4.0",
"eslint-plugin-testing-library": "7.1.1",
"eslint-plugin-testing-library": "7.2.1",
"prettier": "3.5.3"
}
}

View File

@@ -18,7 +18,7 @@
"eslint-plugin-import": "2.31.0",
"i18next-parser": "9.3.0",
"jest": "29.7.0",
"ts-jest": "29.3.2",
"ts-jest": "29.3.4",
"typescript": "*",
"yargs": "17.7.2"
}

View File

@@ -1,4 +1,11 @@
FROM node:20-alpine AS y-provider-builder
FROM node:22.9-alpine AS base
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
FROM base AS y-provider-builder
WORKDIR /home/frontend/
@@ -15,7 +22,7 @@ COPY ./src/frontend/servers/y-provider ./servers/y-provider
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
FROM node:20-alpine AS y-provider
FROM base AS y-provider
WORKDIR /home/frontend/

View File

@@ -16,10 +16,10 @@
"node": ">=18"
},
"dependencies": {
"@blocknote/server-util": "0.29.1",
"@blocknote/server-util": "0.30.0",
"@hocuspocus/server": "2.15.2",
"@sentry/node": "9.15.0",
"@sentry/profiling-node": "9.15.0",
"@sentry/node": "9.19.0",
"@sentry/profiling-node": "9.19.0",
"axios": "1.9.0",
"cors": "2.8.5",
"express": "5.1.0",
@@ -30,8 +30,8 @@
},
"devDependencies": {
"@hocuspocus/provider": "2.15.2",
"@types/cors": "2.8.17",
"@types/express": "5.0.1",
"@types/cors": "2.8.18",
"@types/express": "5.0.2",
"@types/express-ws": "3.0.5",
"@types/jest": "29.5.14",
"@types/node": "*",
@@ -41,8 +41,8 @@
"eslint-config-impress": "*",
"jest": "29.7.0",
"nodemon": "3.1.10",
"supertest": "7.1.0",
"ts-jest": "29.3.2",
"supertest": "7.1.1",
"ts-jest": "29.3.4",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"typescript": "*",

File diff suppressed because it is too large Load Diff