mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-09 00:22:46 +02:00
Compare commits
19 Commits
qbey/add-r
...
feat/file-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
309710ca29 | ||
|
|
7fe3287a14 | ||
|
|
9462690f14 | ||
|
|
bf999979d2 | ||
|
|
09d3ff3754 | ||
|
|
6e5d005dee | ||
|
|
6377c8fcca | ||
|
|
3c8cacc048 | ||
|
|
598fb4fa27 | ||
|
|
51618ad081 | ||
|
|
8109d5ba08 | ||
|
|
e4d0179bbe | ||
|
|
9d3dfb6de7 | ||
|
|
0da042f887 | ||
|
|
6cd0cd0689 | ||
|
|
10b088599c | ||
|
|
62d1bc6473 | ||
|
|
fc1d33268c | ||
|
|
95833fa5ec |
6
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -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 -->
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
@@ -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! -->
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
@@ -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! 👌
|
||||
|
||||
|
||||
2
.github/workflows/crowdin_upload.yml
vendored
2
.github/workflows/crowdin_upload.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/impress.yml
vendored
4
.github/workflows/impress.yml
vendored
@@ -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]
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
87
README.md
87
README.md
@@ -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
BIN
docs/assets/banner-docs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
37
docs/env.md
37
docs/env.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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
0
secu-audit.md
Normal 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
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"'
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -102,7 +102,7 @@ export const verifyDocName = async (page: Page, docName: string) => {
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
|
||||
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
|
||||
fillText: string = 'user ',
|
||||
) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
|
||||
@@ -4,6 +4,8 @@ import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
|
||||
import {
|
||||
CONFIG,
|
||||
addNewMember,
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
@@ -363,7 +365,7 @@ test.describe('Doc Editor', () => {
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
link_reach: 'public',
|
||||
link_reach: 'restricted',
|
||||
link_role: 'editor',
|
||||
created_at: '2021-09-01T09:00:00Z',
|
||||
title: '',
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
...{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,37 +78,40 @@
|
||||
--c--theme--colors--greyscale-750: #353535;
|
||||
--c--theme--colors--greyscale-950: #1e1e1e;
|
||||
--c--theme--colors--greyscale-1000: #161616;
|
||||
--c--theme--colors--danger-050: #fff4f4;
|
||||
--c--theme--colors--blue-500: #417dc4;
|
||||
--c--theme--colors--brown-500: #bd987a;
|
||||
--c--theme--colors--cyan-500: #009099;
|
||||
--c--theme--colors--gold-500: #c3992a;
|
||||
--c--theme--colors--green-500: #00a95f;
|
||||
--c--theme--colors--olive-500: #68a532;
|
||||
--c--theme--colors--orange-500: #e4794a;
|
||||
--c--theme--colors--purple-500: #a558a0;
|
||||
--c--theme--colors--red-500: #e1000f;
|
||||
--c--theme--colors--yellow-500: #b7a73f;
|
||||
--c--theme--colors--rose-500: #e18b76;
|
||||
--c--theme--colors--primary-action: #1212ff;
|
||||
--c--theme--colors--primary-bg: #fafafa;
|
||||
--c--theme--colors--blue-400: #7ab1e8;
|
||||
--c--theme--colors--blue-500: #417dc4;
|
||||
--c--theme--colors--blue-600: #3558a2;
|
||||
--c--theme--colors--brown-400: #e6be92;
|
||||
--c--theme--colors--brown-500: #bd987a;
|
||||
--c--theme--colors--brown-600: #745b47;
|
||||
--c--theme--colors--cyan-400: #34bab5;
|
||||
--c--theme--colors--cyan-500: #009099;
|
||||
--c--theme--colors--cyan-600: #006a6f;
|
||||
--c--theme--colors--gold-400: #ffca00;
|
||||
--c--theme--colors--gold-500: #c3992a;
|
||||
--c--theme--colors--gold-600: #695240;
|
||||
--c--theme--colors--green-400: #34cb6a;
|
||||
--c--theme--colors--green-500: #00a95f;
|
||||
--c--theme--colors--green-600: #297254;
|
||||
--c--theme--colors--olive-400: #99c221;
|
||||
--c--theme--colors--olive-500: #68a532;
|
||||
--c--theme--colors--olive-600: #447049;
|
||||
--c--theme--colors--orange-400: #ff732c;
|
||||
--c--theme--colors--orange-500: #e4794a;
|
||||
--c--theme--colors--orange-600: #755348;
|
||||
--c--theme--colors--pink-400: #ffb7ae;
|
||||
--c--theme--colors--pink-500: #e18b76;
|
||||
--c--theme--colors--pink-600: #8d533e;
|
||||
--c--theme--colors--purple-400: #ce70cc;
|
||||
--c--theme--colors--purple-500: #a558a0;
|
||||
--c--theme--colors--purple-600: #6e445a;
|
||||
--c--theme--colors--yellow-400: #d8c634;
|
||||
--c--theme--colors--yellow-500: #b7a73f;
|
||||
--c--theme--colors--yellow-600: #66673d;
|
||||
--c--theme--font--sizes--h1: 2rem;
|
||||
--c--theme--font--sizes--h2: 1.75rem;
|
||||
@@ -192,6 +195,10 @@
|
||||
--c--theme--logo--widthfooter: 220px;
|
||||
--c--theme--logo--alt: gouvernement logo;
|
||||
--c--components--modal--width-small: 342px;
|
||||
--c--components--tooltip--padding: 4px 8px;
|
||||
--c--components--tooltip--background-color: var(
|
||||
--c--theme--colors--greyscale-1000
|
||||
);
|
||||
--c--components--button--medium-height: 40px;
|
||||
--c--components--button--medium-text-height: 40px;
|
||||
--c--components--button--border-radius: 4px;
|
||||
@@ -448,6 +455,36 @@
|
||||
--c--theme--colors--greyscale-950
|
||||
);
|
||||
--c--components--forms-select--font-size: 14px;
|
||||
--c--components--badge--font-size: var(--c--theme--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 4px;
|
||||
--c--components--badge--padding-inline: var(--c--theme--spacings--xs);
|
||||
--c--components--badge--padding-block: var(--c--theme--spacings--2xs);
|
||||
--c--components--badge--accent--background-color: var(
|
||||
--c--theme--colors--primary-100
|
||||
);
|
||||
--c--components--badge--accent--color: var(--c--theme--colors--primary-600);
|
||||
--c--components--badge--neutral--background-color: var(
|
||||
--c--theme--colors--greyscale-100
|
||||
);
|
||||
--c--components--badge--neutral--color: var(
|
||||
--c--theme--colors--greyscale-600
|
||||
);
|
||||
--c--components--badge--danger--background-color: var(
|
||||
--c--theme--colors--danger-100
|
||||
);
|
||||
--c--components--badge--danger--color: var(--c--theme--colors--danger-600);
|
||||
--c--components--badge--success--background-color: var(
|
||||
--c--theme--colors--success-100
|
||||
);
|
||||
--c--components--badge--success--color: var(--c--theme--colors--success-600);
|
||||
--c--components--badge--warning--background-color: var(
|
||||
--c--theme--colors--warning-100
|
||||
);
|
||||
--c--components--badge--warning--color: var(--c--theme--colors--warning-600);
|
||||
--c--components--badge--info--background-color: var(
|
||||
--c--theme--colors--info-100
|
||||
);
|
||||
--c--components--badge--info--color: var(--c--theme--colors--info-600);
|
||||
--c--components--la-gauffre--activated: true;
|
||||
--c--components--home-proconnect--activated: true;
|
||||
}
|
||||
@@ -817,6 +854,54 @@
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
}
|
||||
|
||||
.clr-danger-050 {
|
||||
color: var(--c--theme--colors--danger-050);
|
||||
}
|
||||
|
||||
.clr-blue-500 {
|
||||
color: var(--c--theme--colors--blue-500);
|
||||
}
|
||||
|
||||
.clr-brown-500 {
|
||||
color: var(--c--theme--colors--brown-500);
|
||||
}
|
||||
|
||||
.clr-cyan-500 {
|
||||
color: var(--c--theme--colors--cyan-500);
|
||||
}
|
||||
|
||||
.clr-gold-500 {
|
||||
color: var(--c--theme--colors--gold-500);
|
||||
}
|
||||
|
||||
.clr-green-500 {
|
||||
color: var(--c--theme--colors--green-500);
|
||||
}
|
||||
|
||||
.clr-olive-500 {
|
||||
color: var(--c--theme--colors--olive-500);
|
||||
}
|
||||
|
||||
.clr-orange-500 {
|
||||
color: var(--c--theme--colors--orange-500);
|
||||
}
|
||||
|
||||
.clr-purple-500 {
|
||||
color: var(--c--theme--colors--purple-500);
|
||||
}
|
||||
|
||||
.clr-red-500 {
|
||||
color: var(--c--theme--colors--red-500);
|
||||
}
|
||||
|
||||
.clr-yellow-500 {
|
||||
color: var(--c--theme--colors--yellow-500);
|
||||
}
|
||||
|
||||
.clr-rose-500 {
|
||||
color: var(--c--theme--colors--rose-500);
|
||||
}
|
||||
|
||||
.clr-primary-action {
|
||||
color: var(--c--theme--colors--primary-action);
|
||||
}
|
||||
@@ -829,10 +914,6 @@
|
||||
color: var(--c--theme--colors--blue-400);
|
||||
}
|
||||
|
||||
.clr-blue-500 {
|
||||
color: var(--c--theme--colors--blue-500);
|
||||
}
|
||||
|
||||
.clr-blue-600 {
|
||||
color: var(--c--theme--colors--blue-600);
|
||||
}
|
||||
@@ -841,10 +922,6 @@
|
||||
color: var(--c--theme--colors--brown-400);
|
||||
}
|
||||
|
||||
.clr-brown-500 {
|
||||
color: var(--c--theme--colors--brown-500);
|
||||
}
|
||||
|
||||
.clr-brown-600 {
|
||||
color: var(--c--theme--colors--brown-600);
|
||||
}
|
||||
@@ -853,10 +930,6 @@
|
||||
color: var(--c--theme--colors--cyan-400);
|
||||
}
|
||||
|
||||
.clr-cyan-500 {
|
||||
color: var(--c--theme--colors--cyan-500);
|
||||
}
|
||||
|
||||
.clr-cyan-600 {
|
||||
color: var(--c--theme--colors--cyan-600);
|
||||
}
|
||||
@@ -865,10 +938,6 @@
|
||||
color: var(--c--theme--colors--gold-400);
|
||||
}
|
||||
|
||||
.clr-gold-500 {
|
||||
color: var(--c--theme--colors--gold-500);
|
||||
}
|
||||
|
||||
.clr-gold-600 {
|
||||
color: var(--c--theme--colors--gold-600);
|
||||
}
|
||||
@@ -877,10 +946,6 @@
|
||||
color: var(--c--theme--colors--green-400);
|
||||
}
|
||||
|
||||
.clr-green-500 {
|
||||
color: var(--c--theme--colors--green-500);
|
||||
}
|
||||
|
||||
.clr-green-600 {
|
||||
color: var(--c--theme--colors--green-600);
|
||||
}
|
||||
@@ -889,10 +954,6 @@
|
||||
color: var(--c--theme--colors--olive-400);
|
||||
}
|
||||
|
||||
.clr-olive-500 {
|
||||
color: var(--c--theme--colors--olive-500);
|
||||
}
|
||||
|
||||
.clr-olive-600 {
|
||||
color: var(--c--theme--colors--olive-600);
|
||||
}
|
||||
@@ -901,10 +962,6 @@
|
||||
color: var(--c--theme--colors--orange-400);
|
||||
}
|
||||
|
||||
.clr-orange-500 {
|
||||
color: var(--c--theme--colors--orange-500);
|
||||
}
|
||||
|
||||
.clr-orange-600 {
|
||||
color: var(--c--theme--colors--orange-600);
|
||||
}
|
||||
@@ -925,10 +982,6 @@
|
||||
color: var(--c--theme--colors--purple-400);
|
||||
}
|
||||
|
||||
.clr-purple-500 {
|
||||
color: var(--c--theme--colors--purple-500);
|
||||
}
|
||||
|
||||
.clr-purple-600 {
|
||||
color: var(--c--theme--colors--purple-600);
|
||||
}
|
||||
@@ -937,10 +990,6 @@
|
||||
color: var(--c--theme--colors--yellow-400);
|
||||
}
|
||||
|
||||
.clr-yellow-500 {
|
||||
color: var(--c--theme--colors--yellow-500);
|
||||
}
|
||||
|
||||
.clr-yellow-600 {
|
||||
color: var(--c--theme--colors--yellow-600);
|
||||
}
|
||||
@@ -1261,6 +1310,54 @@
|
||||
background-color: var(--c--theme--colors--greyscale-1000);
|
||||
}
|
||||
|
||||
.bg-danger-050 {
|
||||
background-color: var(--c--theme--colors--danger-050);
|
||||
}
|
||||
|
||||
.bg-blue-500 {
|
||||
background-color: var(--c--theme--colors--blue-500);
|
||||
}
|
||||
|
||||
.bg-brown-500 {
|
||||
background-color: var(--c--theme--colors--brown-500);
|
||||
}
|
||||
|
||||
.bg-cyan-500 {
|
||||
background-color: var(--c--theme--colors--cyan-500);
|
||||
}
|
||||
|
||||
.bg-gold-500 {
|
||||
background-color: var(--c--theme--colors--gold-500);
|
||||
}
|
||||
|
||||
.bg-green-500 {
|
||||
background-color: var(--c--theme--colors--green-500);
|
||||
}
|
||||
|
||||
.bg-olive-500 {
|
||||
background-color: var(--c--theme--colors--olive-500);
|
||||
}
|
||||
|
||||
.bg-orange-500 {
|
||||
background-color: var(--c--theme--colors--orange-500);
|
||||
}
|
||||
|
||||
.bg-purple-500 {
|
||||
background-color: var(--c--theme--colors--purple-500);
|
||||
}
|
||||
|
||||
.bg-red-500 {
|
||||
background-color: var(--c--theme--colors--red-500);
|
||||
}
|
||||
|
||||
.bg-yellow-500 {
|
||||
background-color: var(--c--theme--colors--yellow-500);
|
||||
}
|
||||
|
||||
.bg-rose-500 {
|
||||
background-color: var(--c--theme--colors--rose-500);
|
||||
}
|
||||
|
||||
.bg-primary-action {
|
||||
background-color: var(--c--theme--colors--primary-action);
|
||||
}
|
||||
@@ -1273,10 +1370,6 @@
|
||||
background-color: var(--c--theme--colors--blue-400);
|
||||
}
|
||||
|
||||
.bg-blue-500 {
|
||||
background-color: var(--c--theme--colors--blue-500);
|
||||
}
|
||||
|
||||
.bg-blue-600 {
|
||||
background-color: var(--c--theme--colors--blue-600);
|
||||
}
|
||||
@@ -1285,10 +1378,6 @@
|
||||
background-color: var(--c--theme--colors--brown-400);
|
||||
}
|
||||
|
||||
.bg-brown-500 {
|
||||
background-color: var(--c--theme--colors--brown-500);
|
||||
}
|
||||
|
||||
.bg-brown-600 {
|
||||
background-color: var(--c--theme--colors--brown-600);
|
||||
}
|
||||
@@ -1297,10 +1386,6 @@
|
||||
background-color: var(--c--theme--colors--cyan-400);
|
||||
}
|
||||
|
||||
.bg-cyan-500 {
|
||||
background-color: var(--c--theme--colors--cyan-500);
|
||||
}
|
||||
|
||||
.bg-cyan-600 {
|
||||
background-color: var(--c--theme--colors--cyan-600);
|
||||
}
|
||||
@@ -1309,10 +1394,6 @@
|
||||
background-color: var(--c--theme--colors--gold-400);
|
||||
}
|
||||
|
||||
.bg-gold-500 {
|
||||
background-color: var(--c--theme--colors--gold-500);
|
||||
}
|
||||
|
||||
.bg-gold-600 {
|
||||
background-color: var(--c--theme--colors--gold-600);
|
||||
}
|
||||
@@ -1321,10 +1402,6 @@
|
||||
background-color: var(--c--theme--colors--green-400);
|
||||
}
|
||||
|
||||
.bg-green-500 {
|
||||
background-color: var(--c--theme--colors--green-500);
|
||||
}
|
||||
|
||||
.bg-green-600 {
|
||||
background-color: var(--c--theme--colors--green-600);
|
||||
}
|
||||
@@ -1333,10 +1410,6 @@
|
||||
background-color: var(--c--theme--colors--olive-400);
|
||||
}
|
||||
|
||||
.bg-olive-500 {
|
||||
background-color: var(--c--theme--colors--olive-500);
|
||||
}
|
||||
|
||||
.bg-olive-600 {
|
||||
background-color: var(--c--theme--colors--olive-600);
|
||||
}
|
||||
@@ -1345,10 +1418,6 @@
|
||||
background-color: var(--c--theme--colors--orange-400);
|
||||
}
|
||||
|
||||
.bg-orange-500 {
|
||||
background-color: var(--c--theme--colors--orange-500);
|
||||
}
|
||||
|
||||
.bg-orange-600 {
|
||||
background-color: var(--c--theme--colors--orange-600);
|
||||
}
|
||||
@@ -1369,10 +1438,6 @@
|
||||
background-color: var(--c--theme--colors--purple-400);
|
||||
}
|
||||
|
||||
.bg-purple-500 {
|
||||
background-color: var(--c--theme--colors--purple-500);
|
||||
}
|
||||
|
||||
.bg-purple-600 {
|
||||
background-color: var(--c--theme--colors--purple-600);
|
||||
}
|
||||
@@ -1381,10 +1446,6 @@
|
||||
background-color: var(--c--theme--colors--yellow-400);
|
||||
}
|
||||
|
||||
.bg-yellow-500 {
|
||||
background-color: var(--c--theme--colors--yellow-500);
|
||||
}
|
||||
|
||||
.bg-yellow-600 {
|
||||
background-color: var(--c--theme--colors--yellow-600);
|
||||
}
|
||||
|
||||
@@ -82,37 +82,40 @@ export const tokens = {
|
||||
'greyscale-750': '#353535',
|
||||
'greyscale-950': '#1E1E1E',
|
||||
'greyscale-1000': '#161616',
|
||||
'danger-050': '#FFF4F4',
|
||||
'blue-500': '#417DC4',
|
||||
'brown-500': '#BD987A',
|
||||
'cyan-500': '#009099',
|
||||
'gold-500': '#C3992A',
|
||||
'green-500': '#00A95F',
|
||||
'olive-500': '#68A532',
|
||||
'orange-500': '#E4794A',
|
||||
'purple-500': '#A558A0',
|
||||
'red-500': '#E1000F',
|
||||
'yellow-500': '#B7A73F',
|
||||
'rose-500': '#E18B76',
|
||||
'primary-action': '#1212FF',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'blue-400': '#7AB1E8',
|
||||
'blue-500': '#417DC4',
|
||||
'blue-600': '#3558A2',
|
||||
'brown-400': '#E6BE92',
|
||||
'brown-500': '#BD987A',
|
||||
'brown-600': '#745B47',
|
||||
'cyan-400': '#34BAB5',
|
||||
'cyan-500': '#009099',
|
||||
'cyan-600': '#006A6F',
|
||||
'gold-400': '#FFCA00',
|
||||
'gold-500': '#C3992A',
|
||||
'gold-600': '#695240',
|
||||
'green-400': '#34CB6A',
|
||||
'green-500': '#00A95F',
|
||||
'green-600': '#297254',
|
||||
'olive-400': '#99C221',
|
||||
'olive-500': '#68A532',
|
||||
'olive-600': '#447049',
|
||||
'orange-400': '#FF732C',
|
||||
'orange-500': '#E4794A',
|
||||
'orange-600': '#755348',
|
||||
'pink-400': '#FFB7AE',
|
||||
'pink-500': '#E18B76',
|
||||
'pink-600': '#8D533E',
|
||||
'purple-400': '#CE70CC',
|
||||
'purple-500': '#A558A0',
|
||||
'purple-600': '#6E445A',
|
||||
'yellow-400': '#D8C634',
|
||||
'yellow-500': '#B7A73F',
|
||||
'yellow-600': '#66673D',
|
||||
},
|
||||
font: {
|
||||
@@ -214,70 +217,65 @@ export const tokens = {
|
||||
},
|
||||
components: {
|
||||
modal: { 'width-small': '342px' },
|
||||
tooltip: { padding: '4px 8px', 'background-color': '#161616' },
|
||||
button: {
|
||||
'medium-height': '40px',
|
||||
'medium-text-height': '40px',
|
||||
'border-radius': '4px',
|
||||
'small-height': '26px',
|
||||
primary: {
|
||||
'background--color': 'var(--c--theme--colors--primary-text)',
|
||||
'background--color': '#000091',
|
||||
'background--color-hover': '#1212ff',
|
||||
'background--color-active': '#2323ff',
|
||||
'background--color-disabled':
|
||||
'var(--c--theme--colors--greyscale-100)',
|
||||
'background--color-disabled': '#eee',
|
||||
color: '#fff',
|
||||
'color-hover': '#fff',
|
||||
'color-active': '#fff',
|
||||
'color-focus-visible': '#fff',
|
||||
disabled: 'var(--c--theme--colors--greyscale-500)',
|
||||
disabled: '#7C7C7C',
|
||||
},
|
||||
'primary-text': {
|
||||
'background--color': 'var(--c--theme--colors--primary-text)',
|
||||
'background--color-hover': 'var(--c--theme--colors--greyscale-100)',
|
||||
'background--color-active': 'var(--c--theme--colors--primary-100)',
|
||||
'background--color': '#000091',
|
||||
'background--color-hover': '#eee',
|
||||
'background--color-active': '#ECECFE',
|
||||
'background--color-focus-visible': '#fff',
|
||||
'background--color-disabled':
|
||||
'var(--c--theme--colors--greyscale-000)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-800)',
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
'background--color-disabled': '#fff',
|
||||
color: '#000091',
|
||||
'color-hover': '#000091',
|
||||
disabled: '#929292',
|
||||
},
|
||||
secondary: {
|
||||
'background--color-hover': '#F6F6F6',
|
||||
'background--color-active': '#EDEDED',
|
||||
'background--color-focus-visible':
|
||||
'var(--c--theme--colors--greyscale-000)',
|
||||
'background--disabled': 'var(--c--theme--colors--greyscale-000)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
'border--color': 'var(--c--theme--colors--greyscale-300)',
|
||||
'border--color-hover': 'var(--c--theme--colors--greyscale-300)',
|
||||
'border--color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
'background--color-focus-visible': '#fff',
|
||||
'background--disabled': '#fff',
|
||||
color: '#000091',
|
||||
'border--color': '#CECECE',
|
||||
'border--color-hover': '#CECECE',
|
||||
'border--color-disabled': '#CECECE',
|
||||
disabled: '#929292',
|
||||
},
|
||||
tertiary: {
|
||||
'background--color': 'var(--c--theme--colors--primary-100)',
|
||||
'background--color-focus-visible':
|
||||
'var(--c--theme--colors--primary-100)',
|
||||
'background--color-hover': 'var(--c--theme--colors--primary-300)',
|
||||
'background--color-active': 'var(--c--theme--colors--primary-300)',
|
||||
'background--disabled': 'var(--c--theme--colors--primary-050)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
disabled: 'var(--c--theme--colors--primary-300)',
|
||||
'background--color': '#ECECFE',
|
||||
'background--color-focus-visible': '#ECECFE',
|
||||
'background--color-hover': '#CACAFB',
|
||||
'background--color-active': '#CACAFB',
|
||||
'background--disabled': '#F5F5FE',
|
||||
color: '#000091',
|
||||
disabled: '#CACAFB',
|
||||
},
|
||||
'tertiary-text': {
|
||||
'background--color-hover': 'var(--c--theme--colors--greyscale-100)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
'background--color-hover': '#eee',
|
||||
'color-hover': '#000091',
|
||||
color: '#313178',
|
||||
},
|
||||
danger: {
|
||||
'color-hover': 'white',
|
||||
'background--color': 'var(--c--theme--colors--danger-600)',
|
||||
'background--color': '#CE0500',
|
||||
'background--color-hover': '#FF2725',
|
||||
'background--color-focus-visible':
|
||||
'var(--c--theme--colors--danger-600)',
|
||||
'background--color-disabled':
|
||||
'var(--c--theme--colors--greyscale-100)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-400)',
|
||||
'background--color-focus-visible': '#CE0500',
|
||||
'background--color-disabled': '#eee',
|
||||
'color-disabled': '#929292',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
@@ -288,22 +286,22 @@ export const tokens = {
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'border-radius': '4px',
|
||||
'border-color': 'var(--c--theme--colors--primary-800)',
|
||||
'background-color--hover': 'var(--c--theme--colors--greyscale-100)',
|
||||
'border--color-disabled': 'var(--c--theme--colors--greyscale-200)',
|
||||
'border--color': 'var(--c--theme--colors--primary-800)',
|
||||
'background--disabled': 'var(--c--theme--colors--greyscale-200)',
|
||||
'background--enable': 'var(--c--theme--colors--primary-800)',
|
||||
'check--disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'check--enable': 'var(--c--theme--colors--greyscale-000)',
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'label--color': 'var(--c--theme--colors--greyscale-1000)',
|
||||
'label--size': 'var(--c--theme--font--sizes--sm)',
|
||||
'border-color': '#000091',
|
||||
'background-color--hover': '#eee',
|
||||
'border--color-disabled': '#E5E5E5',
|
||||
'border--color': '#000091',
|
||||
'background--disabled': '#E5E5E5',
|
||||
'background--enable': '#000091',
|
||||
'check--disabled': '#CECECE',
|
||||
'check--enable': '#fff',
|
||||
color: '#000091',
|
||||
'label--color': '#161616',
|
||||
'label--size': '0.875rem',
|
||||
'label--weight': '500',
|
||||
'text--color': 'var(--c--theme--colors--greyscale-600)',
|
||||
'text--size': 'var(--c--theme--font--sizes--s)',
|
||||
'text--color': '#666666',
|
||||
'text--size': '0.75rem',
|
||||
'text--weight': '400',
|
||||
'text--color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'text--color-disabled': '#CECECE',
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color--small': '#1E1E1E',
|
||||
@@ -312,20 +310,18 @@ export const tokens = {
|
||||
'label-color--big--disabled': '#CECECE',
|
||||
},
|
||||
'forms-radio': {
|
||||
'border-color': 'var(--c--theme--colors--primary-800)',
|
||||
'background-color': 'var(--c--theme--colors--greyscale-000)',
|
||||
'accent-color': 'var(--c--theme--colors--primary-800)',
|
||||
'accent-color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'border-color': '#000091',
|
||||
'background-color': '#fff',
|
||||
'accent-color': '#000091',
|
||||
'accent-color-disabled': '#CECECE',
|
||||
},
|
||||
'forms-switch': {
|
||||
'border--color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'border--color': 'var(--c--theme--colors--primary-800)',
|
||||
'border--color-disabled': '#CECECE',
|
||||
'border--color': '#000091',
|
||||
'handle-background-color': 'white',
|
||||
'handle-background-color--disabled':
|
||||
'var(--c--theme--colors--greyscale-000)',
|
||||
'rail-background-color--disabled':
|
||||
'var(--c--theme--colors--greyscale-000)',
|
||||
'accent-color': 'var(--c--theme--colors--primary-800)',
|
||||
'handle-background-color--disabled': '#fff',
|
||||
'rail-background-color--disabled': '#fff',
|
||||
'accent-color': '#000091',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'label-color--focus': '#161616',
|
||||
@@ -358,6 +354,18 @@ export const tokens = {
|
||||
'value-color': '#1E1E1E',
|
||||
'font-size': '14px',
|
||||
},
|
||||
badge: {
|
||||
'font-size': '0.75rem',
|
||||
'border-radius': '4px',
|
||||
'padding-inline': '0.5rem',
|
||||
'padding-block': '0.375rem',
|
||||
accent: { 'background-color': '#ECECFE', color: '#313178' },
|
||||
neutral: { 'background-color': '#eee', color: '#666666' },
|
||||
danger: { 'background-color': '#FFE9E9', color: '#CE0500' },
|
||||
success: { 'background-color': '#dffee6', color: '#18753c' },
|
||||
warning: { 'background-color': '#fff4f3', color: '#b34000' },
|
||||
info: { 'background-color': '#E8EDFF', color: '#0063CB' },
|
||||
},
|
||||
'la-gauffre': { activated: true },
|
||||
'home-proconnect': { activated: true },
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './conf';
|
||||
export * from './hooks';
|
||||
export * from './utils';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,7 +25,6 @@ interface DocEditorProps {
|
||||
|
||||
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const isVersion = !!versionId && typeof versionId === 'string';
|
||||
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
@@ -21,6 +21,7 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
|
||||
const blob = await exporter.resolveFile(block.props.url);
|
||||
let pngConverted: string | undefined;
|
||||
let dimensions: { width: number; height: number } | undefined;
|
||||
let previewWidth = block.props.previewWidth || undefined;
|
||||
|
||||
if (!blob.type.includes('image')) {
|
||||
return [];
|
||||
@@ -28,7 +29,9 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
|
||||
|
||||
if (blob.type.includes('svg')) {
|
||||
const svgText = await blob.text();
|
||||
pngConverted = await convertSvgToPng(svgText, block.props.previewWidth);
|
||||
const FALLBACK_SIZE = 536;
|
||||
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
|
||||
pngConverted = await convertSvgToPng(svgText, previewWidth);
|
||||
const img = new Image();
|
||||
img.src = pngConverted;
|
||||
await new Promise((resolve) => {
|
||||
@@ -47,8 +50,7 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
|
||||
|
||||
const { width, height } = dimensions;
|
||||
|
||||
let previewWidth = block.props.previewWidth;
|
||||
if (previewWidth > MAX_WIDTH) {
|
||||
if (previewWidth && previewWidth > MAX_WIDTH) {
|
||||
previewWidth = MAX_WIDTH;
|
||||
}
|
||||
|
||||
@@ -69,8 +71,8 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
|
||||
}
|
||||
: undefined,
|
||||
transformation: {
|
||||
width: previewWidth,
|
||||
height: (previewWidth / width) * height,
|
||||
width: previewWidth || width,
|
||||
height: ((previewWidth || width) / width) * height,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -12,6 +12,7 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
|
||||
async (block, exporter) => {
|
||||
const blob = await exporter.resolveFile(block.props.url);
|
||||
let pngConverted: string | undefined;
|
||||
let width = block.props.previewWidth || undefined;
|
||||
|
||||
if (!blob.type.includes('image')) {
|
||||
return <View wrap={false} />;
|
||||
@@ -19,7 +20,9 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
|
||||
|
||||
if (blob.type.includes('svg')) {
|
||||
const svgText = await blob.text();
|
||||
pngConverted = await convertSvgToPng(svgText, block.props.previewWidth);
|
||||
const FALLBACK_SIZE = 536;
|
||||
width = width || blob.size || FALLBACK_SIZE;
|
||||
pngConverted = await convertSvgToPng(svgText, width);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -27,7 +30,7 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
|
||||
<Image
|
||||
src={pngConverted || blob}
|
||||
style={{
|
||||
width: block.props.previewWidth * PIXELS_PER_POINT,
|
||||
width: width ? width * PIXELS_PER_POINT : undefined,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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))} ·
|
||||
<Text
|
||||
$variation="600"
|
||||
$size="s"
|
||||
$weight="bold"
|
||||
$theme={isEditable ? 'greyscale' : 'warning'}
|
||||
>
|
||||
{transRole(
|
||||
isEditable
|
||||
? currentDocRole(doc.abilities)
|
||||
: Role.READER,
|
||||
)}
|
||||
·
|
||||
</Text>
|
||||
<Text $variation="600" $size="s">
|
||||
{t('Last update: {{update}}', {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useCollaboration';
|
||||
export * from './useTrans';
|
||||
export * from './useCopyDocLink';
|
||||
export * from './useIsCollaborativeEditable';
|
||||
export * from './useTrans';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ describe('useSWRegister', () => {
|
||||
value: {
|
||||
register: registerSpy,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './hooks/useOffline';
|
||||
export * from './hooks/useSWRegister';
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user