Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cecfec0b7 | |||
| ec663b601f | |||
| 4de26c331b | |||
| f66a8e3f1d | |||
| 4f3e0ef5b3 | |||
| 7b363d4624 | |||
| bf999979d2 | |||
| 09d3ff3754 | |||
| 6e5d005dee | |||
| 6377c8fcca | |||
| 3c8cacc048 | |||
| 598fb4fa27 | |||
| 51618ad081 | |||
| 8109d5ba08 | |||
| e4d0179bbe | |||
| 9d3dfb6de7 | |||
| 0da042f887 | |||
| 6cd0cd0689 | |||
| 10b088599c | |||
| 62d1bc6473 | |||
| fc1d33268c | |||
| 95833fa5ec | |||
| dd6e0b5072 | |||
| 95d3a8cd18 | |||
| 4f126ab824 | |||
| fb90c13dad | |||
| 4118d79525 | |||
| 5848f43cb4 | |||
| 4b0fd223c8 | |||
| 31d0733851 | |||
| 16e20e984c | |||
| 76c28760dc | |||
| d856abb5d8 | |||
| 25abd964de | |||
| a070e1dd87 | |||
| 37d9ae8cca | |||
| 29ea6b8ef7 | |||
| a692fa6f39 | |||
| 4d541c5d52 | |||
| e5f029ad1d | |||
| bd79f84e07 | |||
| a070f56339 | |||
| 02478acb3f | |||
| 23aa497db0 | |||
| d48436bffb | |||
| 41e4c45934 |
@@ -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 -->
|
||||
|
||||
@@ -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! -->
|
||||
|
||||
@@ -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! 👌
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,7 +79,9 @@ jobs:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
build-args: |
|
||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
PUBLISH_AS_MIT=false
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -61,6 +61,25 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lint-spell-mistakes:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Install codespell
|
||||
run: pip install --user codespell
|
||||
- name: Check for typos
|
||||
run: |
|
||||
codespell \
|
||||
--check-filenames \
|
||||
--ignore-words-list "Dokument,afterAll,excpt,statics" \
|
||||
--skip "./git/" \
|
||||
--skip "**/*.po" \
|
||||
--skip "**/*.pot" \
|
||||
--skip "**/*.json" \
|
||||
--skip "**/yarn.lock"
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -72,7 +91,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.12.6"
|
||||
python-version: "3.13.3"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
@@ -167,7 +186,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.12.6"
|
||||
python-version: "3.13.3"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
+26
-1
@@ -8,11 +8,35 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) allow setting session cookie age via env var #977
|
||||
- ✨(backend) allow theme customnization using a configuration file #948
|
||||
- ✨ Add a custom callout block to the editor #892
|
||||
- 🚩(frontend) version MIT only #911
|
||||
- ✨(backend) integrate maleware_detection from django-lasuite #936
|
||||
- 🩺(CI) add lint spell mistakes #954
|
||||
- 🛂(frontend) block edition to not connected users #945
|
||||
|
||||
## Changed
|
||||
|
||||
- 📝(frontend) Update documentation
|
||||
- ✅(frontend) Improve tests coverage
|
||||
- ⬆️(docker) upgrade backend image to python 3.13 #973
|
||||
- ⬆️(docker) upgrade node images to alpine 3.21
|
||||
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(back) remove footer endpoint
|
||||
|
||||
## [3.2.1] - 2025-05-06
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) fix list copy paste #943
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
|
||||
|
||||
## [3.2.0] - 2025-05-05
|
||||
|
||||
@@ -20,6 +44,7 @@ and this project adheres to
|
||||
|
||||
- 🚸(backend) make document search on title accent-insensitive #874
|
||||
- 🚩 add homepage feature flag #861
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
- ✨(settings) Allow configuring PKCE for the SSO #886
|
||||
- 🌐(i18n) activate chinese and spanish languages #884
|
||||
- 🔧(backend) allow overwriting the data directory #893
|
||||
@@ -537,7 +562,7 @@ and this project adheres to
|
||||
- ⚡️(e2e) unique login between tests (#80)
|
||||
- ⚡️(CI) improve e2e job (#86)
|
||||
- ♻️(frontend) improve the error and message info ui (#93)
|
||||
- ✏️(frontend) change all occurences of pad to doc (#99)
|
||||
- ✏️(frontend) change all occurrences of pad to doc (#99)
|
||||
|
||||
## Fixed
|
||||
|
||||
|
||||
+11
-7
@@ -42,34 +42,38 @@ Examples of unacceptable behavior include:
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this
|
||||
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of the following Code of Conduct
|
||||
|
||||
## Code of Conduct:
|
||||
|
||||
1. Correction
|
||||
### 1. Correction
|
||||
|
||||
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
2. Warning
|
||||
|
||||
### 2. Warning
|
||||
|
||||
Community Impact: A violation through a single incident or series of actions.
|
||||
|
||||
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
3. Temporary Ban
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
4. Permanent Ban
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
Consequence: A permanent ban from any sort of public interaction within the community.
|
||||
Attribution
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.
|
||||
Community Impact Guidelines were inspired by Mozilla's [code of conduct enforcement ladder](https://github.com/mozilla/inclusion/blob/master/code-of-conduct-enforcement/consequence-ladder.md).
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
||||
+21
-7
@@ -2,16 +2,16 @@
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions on how to run Docs locally.
|
||||
|
||||
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
|
||||
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
|
||||
|
||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||
|
||||
## Help us with translations
|
||||
|
||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||
Your language is not there? Request it on our Crowdin page 😊.
|
||||
Your language is not there? Request it on our Crowdin page 😊 or ping us on [Matrix](https://matrix.to/#/#docs-official:matrix.org) and let us know if you can help with translations and/or proofreading.
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
@@ -35,10 +35,14 @@ All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
* **title**: A short, descriptive title for the change (*)
|
||||
* **blank line after the commit title
|
||||
* **description**: Include additional details on why you made the changes (**).
|
||||
|
||||
(*) ⚠️ **Make sure you add no space between the emoji and the (type) but add a space after the closing parenthesis of the type and use no caps!**
|
||||
(**) ⚠️ **Commit description message is mandatory and shouldn't be too long**
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
@@ -66,7 +70,9 @@ Please add a line to the changelog describing your development. The changelog en
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||
|
||||
### Don't forget to:
|
||||
- check your commits
|
||||
- signoff your commits
|
||||
- sign your commits with your key (SSH, GPG etc.)
|
||||
- check your commits (see warnings above)
|
||||
- check the linting: `make lint && make frontend-lint`
|
||||
- check the tests: `make test`
|
||||
- add a changelog entry
|
||||
@@ -86,3 +92,11 @@ Make sure that all new features or fixes have corresponding tests. Run the test
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
|
||||
## Contribute to BlockNote
|
||||
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
|
||||
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
|
||||
|
||||
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
|
||||
|
||||
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/docs">
|
||||
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
|
||||
<img alt="Docs" src="/docs/assets/banner-docs.png" width="100%" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
|
||||
<a href="https://github.com/suitenumerique/docs/stargazers/">
|
||||
<img src="https://img.shields.io/github/stars/suitenumerique/docs" alt="">
|
||||
</a>
|
||||
<a href='https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
|
||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
|
||||
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/docs"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://matrix.to/#/#docs-official:matrix.org">
|
||||
Chat on Matrix
|
||||
@@ -20,43 +26,52 @@ Welcome to Docs! The open source document editor where your notes can become kno
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# La Suite Docs : Collaborative Text Editing
|
||||
Docs, where your notes can become knowledge through live collaboration.
|
||||
|
||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||
|
||||
## Why use Docs ❓
|
||||
|
||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||
|
||||
### Write
|
||||
* 😌 Simple collaborative editing without the formatting complexity of markdown
|
||||
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
|
||||
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
|
||||
* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
|
||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
||||
It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence.
|
||||
|
||||
### Collaborate
|
||||
* 🤝 Collaborate with your team in real time
|
||||
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
|
||||
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
|
||||
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 05/2025`
|
||||
### Write
|
||||
* 😌 Get simple, accessible online editing for your team.
|
||||
* 💅 Create clean documents with beautiful formatting options.
|
||||
* 🖌️ Focus on your content using either the in-line editor, or [the Markdown syntax](https://www.markdownguide.org/basic-syntax/).
|
||||
* 🧱 Quickly design your page thanks to the many block types, accessible from the `/` slash commands, as well as keyboard shortcuts.
|
||||
* 🔌 Write offline! Your edits will be synced once you're back online.
|
||||
* ✨ Save time thanks to our AI actions, such as rephrasing, summarizing, fixing typos, translating, etc. You can even turn your selected text into a prompt!
|
||||
|
||||
### Work together
|
||||
* 🤝 Enjoy live editing! See your team collaborate in real time.
|
||||
* 🔒 Keep your information secure thanks to granular access control. Only share with the right people.
|
||||
* 📑 Export your content in multiple formats (`.odt`, `.docx`, `.pdf`) with customizable templates.
|
||||
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
|
||||
|
||||
### Self-host
|
||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
||||
🚀 Docs is easy to install on your own servers
|
||||
|
||||
⚠️ For the PDF and Docx export Docs relies on XL packages from BlockNote licenced in AGPL-3.0. Please make sure you fulfill your obligations regarding BlockNote licensing (see https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE and https://www.blocknotejs.org/about#partner-with-us).
|
||||
Available methods: Helm chart, Nix package
|
||||
|
||||
In the works: Docker Compose, YunoHost
|
||||
|
||||
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
|
||||
|
||||
## Getting started 🔧
|
||||
|
||||
### Test it
|
||||
|
||||
Test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
|
||||
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
|
||||
|
||||
### Run it locally
|
||||
### Run Docs locally
|
||||
|
||||
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution but you can choose any S3 compatible object storage of your choice.
|
||||
> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution.
|
||||
|
||||
**Prerequisite**
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop, then type:
|
||||
|
||||
```shellscript
|
||||
$ docker -v
|
||||
@@ -68,7 +83,7 @@ $ docker compose version
|
||||
Docker Compose version v2.32.4
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
|
||||
> ⚠️ You may need to run the following commands with `sudo`, but this can be avoided by adding your user to the local `docker` group.
|
||||
|
||||
**Project bootstrap**
|
||||
|
||||
@@ -78,13 +93,13 @@ The easiest way to start working on the project is to use [GNU Make](https://www
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
You can access to the project by going to <http://localhost:3000>.
|
||||
|
||||
You will be prompted to log in, the default credentials are:
|
||||
You will be prompted to log in. The default credentials are:
|
||||
|
||||
```
|
||||
username: impress
|
||||
@@ -119,13 +134,13 @@ $ make run-backend
|
||||
|
||||
**Adding content**
|
||||
|
||||
You can create a basic demo site by running:
|
||||
You can create a basic demo site by running this command:
|
||||
|
||||
```shellscript
|
||||
$ make demo
|
||||
```
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
Finally, you can check all available Make rules using this command:
|
||||
|
||||
```shellscript
|
||||
$ make help
|
||||
@@ -133,7 +148,7 @@ $ make help
|
||||
|
||||
**Django admin**
|
||||
|
||||
You can access the Django admin site at
|
||||
You can access the Django admin site at:
|
||||
|
||||
<http://localhost:8071/admin>.
|
||||
|
||||
@@ -145,7 +160,7 @@ $ make superuser
|
||||
|
||||
## Feedback 🙋♂️🙋♀️
|
||||
|
||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||
We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -155,7 +170,7 @@ Want to know where the project is headed? [🗺️ Checkout our roadmap](https:/
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||
|
||||
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||
While Docs is a public-driven initiative, our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||
|
||||
## Contributing 🙌
|
||||
|
||||
@@ -163,9 +178,9 @@ This project is intended to be community-driven, so please, do not hesitate to [
|
||||
|
||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||
|
||||
If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
|
||||
If you intend to make pull requests, see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
|
||||
|
||||
Directory structure:
|
||||
## Directory structure:
|
||||
|
||||
```markdown
|
||||
docs
|
||||
@@ -183,14 +198,14 @@ docs
|
||||
|
||||
### Stack
|
||||
|
||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
|
||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/). We thank the contributors of all these projects for their awesome work!
|
||||
|
||||
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
||||
|
||||
|
||||
### Gov ❤️ open source
|
||||
|
||||
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
|
||||
|
||||
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
||||
|
||||
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
|
||||
|
||||
<p align="center">
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Security is very important to us.
|
||||
|
||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
|
||||
If you have any issue regarding security, please disclose the information responsibly submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
|
||||
|
||||
We appreciate your effort to make Docs more secure.
|
||||
|
||||
|
||||
+1
-2
@@ -155,8 +155,7 @@ services:
|
||||
target: frontend-production
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
Y_PROVIDER_URL: "ws://localhost:4444"
|
||||
MEDIA_URL: "http://localhost:8083"
|
||||
PUBLISH_AS_MIT: "false"
|
||||
SW_DEACTIVATED: "true"
|
||||
image: impress:frontend-development
|
||||
ports:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
+50
-8
@@ -4,7 +4,7 @@ Here we describe all environment variables that can be set for the docs applicat
|
||||
|
||||
## impress-backend container
|
||||
|
||||
These are the environmental variables you can set for the impress-backend container.
|
||||
These are the environment variables you can set for the `impress-backend` container.
|
||||
|
||||
| Option | Description | default |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
@@ -39,7 +39,7 @@ These are the environmental variables you can set for the impress-backend contai
|
||||
| DJANGO_EMAIL_PORT | port used to connect to email host | |
|
||||
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
|
||||
| DJANGO_EMAIL_FROM | email adress used as sender | from@example.com |
|
||||
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
|
||||
@@ -49,24 +49,22 @@ These are the environmental variables you can set for the impress-backend contai
|
||||
| COLLABORATION_WS_URL | collaboration websocket url | |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_FOOTER_FEATURE_ENABLED | frontend feature flag to display the footer | false |
|
||||
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
|
||||
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
|
||||
| FRONTEND_THEME | frontend theme to use | |
|
||||
| POSTHOG_KEY | posthog key for analytics | |
|
||||
| CRISP_WEBSITE_ID | crisp website id for support | |
|
||||
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| OIDC_CREATE_USER | create used on OIDC | false |
|
||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
|
||||
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | Autorization endpoint for OIDC | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
||||
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth paramaters | {} |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
|
||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
|
||||
@@ -76,7 +74,7 @@ These are the environmental variables you can set for the impress-backend contai
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow dupplicate emails | false |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||
@@ -98,3 +96,47 @@ These are the environmental variables you can set for the impress-backend contai
|
||||
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
||||
| REDIS_URL | cache url | redis://redis:6379/1 |
|
||||
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
|
||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||
|
||||
|
||||
## impress-frontend image
|
||||
|
||||
These are the environment variables you can set to build the `impress-frontend` image.
|
||||
|
||||
Depending on how you are building the front-end application, this variable is used in different ways.
|
||||
|
||||
If you want to build the Docker image, this variable is used as an argument in the build command.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest
|
||||
```
|
||||
|
||||
If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`).
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
cd src/frontend/apps/impress
|
||||
NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
|
||||
```
|
||||
|
||||
| Option | Description | default |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
|
||||
| SW_DEACTIVATED | To not install the service worker | |
|
||||
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
|
||||
|
||||
Packages with licences incompatible with the MIT licence:
|
||||
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE)
|
||||
|
||||
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
||||
|
||||
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||
|
||||
|
||||
@@ -82,13 +82,13 @@ backend:
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
|
||||
@@ -133,7 +133,7 @@ OIDC_RP_SCOPES: "openid email"
|
||||
|
||||
You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### Find redis server connexion values
|
||||
### Find redis server connection values
|
||||
|
||||
Docs needs a redis so we start by deploying one:
|
||||
|
||||
@@ -146,7 +146,7 @@ keycloak-postgresql-0 1/1 Running 0 26m
|
||||
redis-master-0 1/1 Running 0 35s
|
||||
```
|
||||
|
||||
### Find postgresql connexion values
|
||||
### Find postgresql connection values
|
||||
|
||||
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
|
||||
|
||||
@@ -173,7 +173,7 @@ POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
### Find s3 bucket connexion values
|
||||
### Find s3 bucket connection values
|
||||
|
||||
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
|
||||
|
||||
@@ -191,7 +191,7 @@ redis-master-0 1/1 Running 0 10m
|
||||
|
||||
## Deployment
|
||||
|
||||
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous informations to the helm chart.
|
||||
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous information to the helm chart.
|
||||
|
||||
```
|
||||
$ helm repo add impress https://suitenumerique.github.io/docs/
|
||||
|
||||
@@ -64,5 +64,3 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=default
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED=True
|
||||
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json
|
||||
|
||||
@@ -4,3 +4,4 @@ BURST_THROTTLE_RATES="200/minute"
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
THEME_CUSTOMIZATION_FILE_PATH="" #force theme_customization to be empty
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from urllib.parse import unquote, urlparse
|
||||
@@ -9,6 +10,7 @@ from django.conf import settings
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import connection, transaction
|
||||
@@ -16,14 +18,13 @@ from django.db import models as db
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
import requests
|
||||
import rest_framework as drf
|
||||
from botocore.exceptions import ClientError
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
@@ -32,7 +33,6 @@ from rest_framework.throttling import UserRateThrottle
|
||||
from core import authentication, enums, models
|
||||
from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
from core.services.config_services import get_footer_json
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
@@ -576,7 +576,7 @@ class DocumentViewSet(
|
||||
queryset, filter_data["is_favorite"]
|
||||
)
|
||||
|
||||
# Apply ordering only now that everyting is filtered and annotated
|
||||
# Apply ordering only now that everything is filtered and annotated
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
self.request, queryset, self
|
||||
)
|
||||
@@ -889,7 +889,7 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
# Compute cache for ancestors links to avoid many queries while computing
|
||||
# abilties for his documents in the tree!
|
||||
# abilities for his documents in the tree!
|
||||
ancestors_links.append(
|
||||
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
|
||||
)
|
||||
@@ -1156,7 +1156,10 @@ class DocumentViewSet(
|
||||
|
||||
# Prepare metadata for storage
|
||||
extra_args = {
|
||||
"Metadata": {"owner": str(request.user.id)},
|
||||
"Metadata": {
|
||||
"owner": str(request.user.id),
|
||||
"status": enums.DocumentAttachmentStatus.PROCESSING,
|
||||
},
|
||||
"ContentType": serializer.validated_data["content_type"],
|
||||
}
|
||||
file_unsafe = ""
|
||||
@@ -1188,6 +1191,8 @@ class DocumentViewSet(
|
||||
document.attachments.append(key)
|
||||
document.save()
|
||||
|
||||
malware_detection.analyse_file(key, document_id=document.id)
|
||||
|
||||
return drf.response.Response(
|
||||
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
|
||||
status=drf.status.HTTP_201_CREATED,
|
||||
@@ -1271,6 +1276,19 @@ class DocumentViewSet(
|
||||
logger.debug("User '%s' lacks permission for attachment", user)
|
||||
raise drf.exceptions.PermissionDenied()
|
||||
|
||||
# Check if the attachment is ready
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
|
||||
metadata = head_resp.get("Metadata", {})
|
||||
# In order to be compatible with existing upload without `status` metadata,
|
||||
# we consider them as ready.
|
||||
if (
|
||||
metadata.get("status", enums.DocumentAttachmentStatus.READY)
|
||||
!= enums.DocumentAttachmentStatus.READY
|
||||
):
|
||||
raise drf.exceptions.PermissionDenied()
|
||||
|
||||
# Generate S3 authorization headers using the extracted URL parameters
|
||||
request = utils.generate_s3_authorization_headers(key)
|
||||
|
||||
@@ -1715,7 +1733,6 @@ class ConfigView(drf.views.APIView):
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
"FRONTEND_FOOTER_FEATURE_ENABLED",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
@@ -1728,23 +1745,41 @@ class ConfigView(drf.views.APIView):
|
||||
if hasattr(settings, setting):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
dict_settings["theme_customization"] = self._load_theme_customization()
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
def _load_theme_customization(self):
|
||||
if not settings.THEME_CUSTOMIZATION_FILE_PATH:
|
||||
return {}
|
||||
|
||||
class FooterView(drf.views.APIView):
|
||||
"""API ViewSet for sharing the footer JSON."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@method_decorator(cache_page(settings.FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT))
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/footer/
|
||||
Return the footer JSON.
|
||||
"""
|
||||
json_footer = (
|
||||
get_footer_json(settings.FRONTEND_URL_JSON_FOOTER)
|
||||
if settings.FRONTEND_URL_JSON_FOOTER
|
||||
else {}
|
||||
cache_key = (
|
||||
f"theme_customization_{slugify(settings.THEME_CUSTOMIZATION_FILE_PATH)}"
|
||||
)
|
||||
return drf.response.Response(json_footer)
|
||||
theme_customization = cache.get(cache_key, {})
|
||||
if theme_customization:
|
||||
return theme_customization
|
||||
|
||||
try:
|
||||
with open(
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8"
|
||||
) as f:
|
||||
theme_customization = json.load(f)
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"Configuration file not found: %s",
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(
|
||||
"Configuration file is not a valid JSON: %s",
|
||||
settings.THEME_CUSTOMIZATION_FILE_PATH,
|
||||
)
|
||||
else:
|
||||
cache.set(
|
||||
cache_key,
|
||||
theme_customization,
|
||||
settings.THEME_CUSTOMIZATION_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
return theme_customization
|
||||
|
||||
@@ -3,6 +3,7 @@ Core application enums declaration
|
||||
"""
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.db import models
|
||||
@@ -38,3 +39,10 @@ class MoveNodePositionChoices(models.TextChoices):
|
||||
LAST_SIBLING = "last-sibling", _("Last sibling")
|
||||
LEFT = "left", _("Left")
|
||||
RIGHT = "right", _("Right")
|
||||
|
||||
|
||||
class DocumentAttachmentStatus(StrEnum):
|
||||
"""Defines the possible statuses for an attachment."""
|
||||
|
||||
PROCESSING = "processing"
|
||||
READY = "ready"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Malware detection callbacks"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from lasuite.malware_detection.enums import ReportStatus
|
||||
|
||||
from core.enums import DocumentAttachmentStatus
|
||||
from core.models import Document
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
security_logger = logging.getLogger("docs.security")
|
||||
|
||||
|
||||
def malware_detection_callback(file_path, status, error_info, **kwargs):
|
||||
"""Malware detection callback"""
|
||||
|
||||
if status == ReportStatus.SAFE:
|
||||
logger.info("File %s is safe", file_path)
|
||||
# Get existing metadata
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
head_resp = s3_client.head_object(Bucket=bucket_name, Key=file_path)
|
||||
metadata = head_resp.get("Metadata", {})
|
||||
metadata.update({"status": DocumentAttachmentStatus.READY})
|
||||
# Update status in metadata
|
||||
s3_client.copy_object(
|
||||
Bucket=bucket_name,
|
||||
CopySource={"Bucket": bucket_name, "Key": file_path},
|
||||
Key=file_path,
|
||||
ContentType=head_resp.get("ContentType"),
|
||||
Metadata=metadata,
|
||||
MetadataDirective="REPLACE",
|
||||
)
|
||||
return
|
||||
|
||||
document_id = kwargs.get("document_id")
|
||||
security_logger.warning(
|
||||
"File %s for document %s is infected with malware. Error info: %s",
|
||||
file_path,
|
||||
document_id,
|
||||
error_info,
|
||||
)
|
||||
|
||||
# Remove the file from the document and change the status to unsafe
|
||||
document = Document.objects.get(pk=document_id)
|
||||
document.attachments.remove(file_path)
|
||||
document.save(update_fields=["attachments"])
|
||||
|
||||
# Delete the file from the storage
|
||||
default_storage.delete(file_path)
|
||||
@@ -44,7 +44,7 @@ AI_ACTIONS = {
|
||||
}
|
||||
|
||||
AI_TRANSLATE = (
|
||||
"Keep the same html stucture and formatting. "
|
||||
"Keep the same html structure and formatting. "
|
||||
"Translate the content in the html to the specified language {language:s}. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
|
||||
@@ -17,7 +17,7 @@ class CollaborationService:
|
||||
def reset_connections(self, room, user_id=None):
|
||||
"""
|
||||
Reset connections of a room in the collaboration server.
|
||||
Reseting a connection means that the user will be disconnected and will
|
||||
Resetting a connection means that the user will be disconnected and will
|
||||
have to reconnect to the collaboration server, with updated rights.
|
||||
"""
|
||||
endpoint = "reset-connections"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Config services."""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_footer_json(footer_json_url: str) -> dict:
|
||||
"""
|
||||
Fetches the footer JSON from the given URL."
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
footer_json_url, timeout=5, headers={"User-Agent": "Docs-Application"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
footer_json = response.json()
|
||||
|
||||
return footer_json
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
logger.error("Failed to fetch footer JSON: %s", e)
|
||||
return {}
|
||||
@@ -575,7 +575,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
existing_user = factories.UserFactory()
|
||||
|
||||
# Build an invitation to the email of an exising identity in the db
|
||||
# Build an invitation to the email of an existing identity in the db
|
||||
invitation_values = {
|
||||
"email": existing_user.email,
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
|
||||
@@ -150,7 +150,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to request AI transform
|
||||
Authenticated who are not related to a document should be able to request AI transform
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -99,7 +99,7 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Keep the same html stucture and formatting. "
|
||||
"Keep the same html structure and formatting. "
|
||||
"Translate the content in the html to the specified language Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
@@ -172,7 +172,7 @@ def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to request AI translate
|
||||
Authenticated who are not related to a document should be able to request AI translate
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
@@ -197,7 +197,7 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Keep the same html stucture and formatting. "
|
||||
"Keep the same html structure and formatting. "
|
||||
"Translate the content in the html to the "
|
||||
"specified language Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
@@ -274,7 +274,7 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Keep the same html stucture and formatting. "
|
||||
"Keep the same html structure and formatting. "
|
||||
"Translate the content in the html to the "
|
||||
"specified language Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
|
||||
@@ -4,6 +4,7 @@ Test file uploads API endpoint for users in impress's core app.
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
@@ -12,6 +13,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.viewsets import malware_detection
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -59,7 +61,8 @@ def test_api_documents_attachment_upload_anonymous_success():
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = APIClient().post(url, {"file": file}, format="multipart")
|
||||
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||
response = APIClient().post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@@ -74,12 +77,13 @@ def test_api_documents_attachment_upload_anonymous_success():
|
||||
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
key = file_path.replace("/media/", "")
|
||||
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
|
||||
assert file_head["Metadata"] == {"owner": "None"}
|
||||
assert file_head["Metadata"] == {"owner": "None", "status": "processing"}
|
||||
assert file_head["ContentType"] == "image/png"
|
||||
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
|
||||
|
||||
@@ -127,7 +131,7 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
|
||||
)
|
||||
def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
||||
"""
|
||||
Autenticated users who are not related to a document should be able to upload
|
||||
Authenticated users who are not related to a document should be able to upload
|
||||
a file when the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
@@ -139,7 +143,8 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@@ -147,6 +152,10 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_id = match.group(1)
|
||||
|
||||
mock_analyse_file.assert_called_once_with(
|
||||
f"{document.id!s}/attachments/{file_id!s}.png", document_id=document.id
|
||||
)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
@@ -210,7 +219,8 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
||||
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@@ -226,11 +236,12 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
||||
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
key = file_path.replace("/media/", "")
|
||||
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id)}
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "status": "processing"}
|
||||
assert file_head["ContentType"] == "image/png"
|
||||
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
|
||||
|
||||
@@ -255,7 +266,7 @@ def test_api_documents_attachment_upload_invalid(client):
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
||||
"""The uploaded file should not exceeed the maximum size in settings."""
|
||||
"""The uploaded file should not exceed the maximum size in settings."""
|
||||
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
|
||||
|
||||
user = factories.UserFactory()
|
||||
@@ -304,7 +315,8 @@ def test_api_documents_attachment_upload_fix_extension(
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
|
||||
file = SimpleUploadedFile(name=name, content=content)
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@@ -324,11 +336,16 @@ def test_api_documents_attachment_upload_fix_extension(
|
||||
uuid.UUID(file_id)
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
key = file_path.replace("/media/", "")
|
||||
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
assert file_head["Metadata"] == {
|
||||
"owner": str(user.id),
|
||||
"is_unsafe": "true",
|
||||
"status": "processing",
|
||||
}
|
||||
assert file_head["ContentType"] == content_type
|
||||
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
|
||||
|
||||
@@ -364,7 +381,8 @@ def test_api_documents_attachment_upload_unsafe():
|
||||
file = SimpleUploadedFile(
|
||||
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
|
||||
)
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@@ -381,11 +399,20 @@ def test_api_documents_attachment_upload_unsafe():
|
||||
file_id = file_id.replace("-unsafe", "")
|
||||
uuid.UUID(file_id)
|
||||
|
||||
key = file_path.replace("/media/", "")
|
||||
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
assert file_head["ContentType"] == "application/octet-stream"
|
||||
assert file_head["Metadata"] == {
|
||||
"owner": str(user.id),
|
||||
"is_unsafe": "true",
|
||||
"status": "processing",
|
||||
}
|
||||
# Depending the libmagic version, the content type may change.
|
||||
assert file_head["ContentType"] in [
|
||||
"application/x-dosexec",
|
||||
"application/octet-stream",
|
||||
]
|
||||
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
|
||||
|
||||
@@ -279,7 +279,7 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback
|
||||
"""
|
||||
It should be possible to create a document on behalf of a pre-existing user for
|
||||
who the sub was not found if the settings allow it. This edge case should not
|
||||
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
|
||||
happen in a healthy OIDC federation but can be useful if an OIDC provider modifies
|
||||
users sub on each login for example...
|
||||
"""
|
||||
user = factories.UserFactory(language="en-us")
|
||||
|
||||
@@ -15,6 +15,7 @@ import requests
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.enums import DocumentAttachmentStatus
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -45,6 +46,7 @@ def test_api_documents_media_auth_anonymous_public():
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
Metadata={"status": DocumentAttachmentStatus.READY},
|
||||
)
|
||||
|
||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||
@@ -93,7 +95,15 @@ def test_api_documents_media_auth_extensions():
|
||||
keys = []
|
||||
for ext in extensions:
|
||||
filename = f"{uuid4()!s}.{ext:s}"
|
||||
keys.append(f"{document_id!s}/attachments/{filename:s}")
|
||||
key = f"{document_id!s}/attachments/{filename:s}"
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
Metadata={"status": DocumentAttachmentStatus.READY},
|
||||
)
|
||||
keys.append(key)
|
||||
|
||||
factories.DocumentFactory(link_reach="public", attachments=keys)
|
||||
|
||||
@@ -142,6 +152,7 @@ def test_api_documents_media_auth_anonymous_attachments():
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
Metadata={"status": DocumentAttachmentStatus.READY},
|
||||
)
|
||||
|
||||
factories.DocumentFactory(id=document_id, link_reach="restricted")
|
||||
@@ -205,6 +216,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
Metadata={"status": DocumentAttachmentStatus.READY},
|
||||
)
|
||||
|
||||
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
|
||||
@@ -283,6 +295,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
Metadata={"status": DocumentAttachmentStatus.READY},
|
||||
)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
@@ -321,3 +334,70 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_documents_media_auth_not_ready_status():
|
||||
"""Attachments with status not ready should not be accessible"""
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
key = f"{document_id!s}/attachments/{filename:s}"
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
Metadata={"status": DocumentAttachmentStatus.PROCESSING},
|
||||
)
|
||||
|
||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_missing_status_metadata():
|
||||
"""Attachments without status metadata should be considered as ready"""
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
key = f"{document_id!s}/attachments/{filename:s}"
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
@@ -310,7 +310,7 @@ def test_api_documents_move_authenticated_deleted_target_as_child(position):
|
||||
def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
|
||||
"""
|
||||
It should not be possible to move a document as a sibling of a deleted target document
|
||||
if the user has no rigths on its parent.
|
||||
if the user has no rights on its parent.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
|
||||
@@ -31,7 +31,7 @@ def get_ydoc_with_mages(image_keys):
|
||||
def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_queries):
|
||||
"""
|
||||
When an anonymous user updates a document, the attachment keys extracted from the
|
||||
updated content should be added to the list of "attachments" ot the document if these
|
||||
updated content should be added to the list of "attachments" to the document if these
|
||||
attachments are already readable by anonymous users.
|
||||
"""
|
||||
image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(4)]
|
||||
@@ -78,7 +78,7 @@ def test_api_documents_update_new_attachment_keys_authenticated(
|
||||
):
|
||||
"""
|
||||
When an authenticated user updates a document, the attachment keys extracted from the
|
||||
updated content should be added to the list of "attachments" ot the document if these
|
||||
updated content should be added to the list of "attachments" to the document if these
|
||||
attachments are already readable by the editing user.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
Test config API endpoints in the Impress core app.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
@@ -16,14 +18,15 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_FEATURE_ENABLED=False,
|
||||
COLLABORATION_WS_URL="http://testcollab/",
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_CSS_URL="http://testcss/",
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED=True,
|
||||
FRONTEND_THEME="test-theme",
|
||||
MEDIA_BASE_URL="http://testserver/",
|
||||
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
SENTRY_DSN="https://sentry.test/123",
|
||||
THEME_CUSTOMIZATION_FILE_PATH="",
|
||||
)
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
def test_api_config(is_authenticated):
|
||||
@@ -42,7 +45,6 @@ def test_api_config(is_authenticated):
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_FOOTER_FEATURE_ENABLED": True,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [
|
||||
["en-us", "English"],
|
||||
@@ -56,4 +58,98 @@ def test_api_config(is_authenticated):
|
||||
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
"SENTRY_DSN": "https://sentry.test/123",
|
||||
"AI_FEATURE_ENABLED": False,
|
||||
"theme_customization": {},
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
THEME_CUSTOMIZATION_FILE_PATH="/not/existing/file.json",
|
||||
)
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
def test_api_config_with_invalid_theme_customization_file(is_authenticated):
|
||||
"""Anonymous users should be allowed to get the configuration."""
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert content["theme_customization"] == {}
|
||||
|
||||
|
||||
@override_settings(
|
||||
THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/invalid.json",
|
||||
)
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
def test_api_config_with_invalid_json_theme_customization_file(is_authenticated, fs):
|
||||
"""Anonymous users should be allowed to get the configuration."""
|
||||
fs.create_file(
|
||||
"/configuration/theme/invalid.json",
|
||||
contents="invalid json",
|
||||
)
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert content["theme_customization"] == {}
|
||||
|
||||
|
||||
@override_settings(
|
||||
THEME_CUSTOMIZATION_FILE_PATH="/configuration/theme/default.json",
|
||||
)
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
def test_api_config_with_theme_customization(is_authenticated, fs):
|
||||
"""Anonymous users should be allowed to get the configuration."""
|
||||
fs.create_file(
|
||||
"/configuration/theme/default.json",
|
||||
contents=json.dumps(
|
||||
{
|
||||
"colors": {
|
||||
"primary": "#000000",
|
||||
"secondary": "#000000",
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert content["theme_customization"] == {
|
||||
"colors": {
|
||||
"primary": "#000000",
|
||||
"secondary": "#000000",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
def test_api_config_with_original_theme_customization(is_authenticated, settings):
|
||||
"""Anonymous users should be allowed to get the configuration."""
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
with open(settings.THEME_CUSTOMIZATION_FILE_PATH, "r", encoding="utf-8") as f:
|
||||
theme_customization = json.load(f)
|
||||
|
||||
assert content["theme_customization"] == theme_customization
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Test the footer API."""
|
||||
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
def test_api_footer_without_settings_configured(settings):
|
||||
"""Test the footer API without settings configured."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = None
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_footer_with_invalid_request(settings):
|
||||
"""Test the footer API with an invalid request."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = "https://invalid-request.com"
|
||||
|
||||
footer_response = responses.get(settings.FRONTEND_URL_JSON_FOOTER, status=404)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
assert footer_response.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_footer_with_invalid_json(settings):
|
||||
"""Test the footer API with an invalid JSON response."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
|
||||
|
||||
footer_response = responses.get(
|
||||
settings.FRONTEND_URL_JSON_FOOTER, status=200, body="invalid json"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
assert footer_response.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_footer_with_valid_json(settings):
|
||||
"""Test the footer API with an invalid JSON response."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
|
||||
|
||||
footer_response = responses.get(
|
||||
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": "bar"}
|
||||
assert footer_response.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_footer_with_valid_json_and_cache(settings):
|
||||
"""Test the footer API with an invalid JSON response."""
|
||||
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
|
||||
|
||||
footer_response = responses.get(
|
||||
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": "bar"}
|
||||
assert footer_response.call_count == 1
|
||||
|
||||
response = client.get("/api/v1.0/footer/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": "bar"}
|
||||
# The cache should have been used
|
||||
assert footer_response.call_count == 1
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Test malware detection callback."""
|
||||
|
||||
import random
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import pytest
|
||||
from lasuite.malware_detection.enums import ReportStatus
|
||||
|
||||
from core.enums import DocumentAttachmentStatus
|
||||
from core.factories import DocumentFactory
|
||||
from core.malware_detection import malware_detection_callback
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(name="safe_file")
|
||||
def fixture_safe_file():
|
||||
"""Create a safe file."""
|
||||
file_path = "test.txt"
|
||||
default_storage.save(file_path, ContentFile("test"))
|
||||
yield file_path
|
||||
default_storage.delete(file_path)
|
||||
|
||||
|
||||
@pytest.fixture(name="unsafe_file")
|
||||
def fixture_unsafe_file():
|
||||
"""Create an unsafe file."""
|
||||
file_path = "unsafe.txt"
|
||||
default_storage.save(file_path, ContentFile("test"))
|
||||
yield file_path
|
||||
|
||||
|
||||
def test_malware_detection_callback_safe_status(safe_file):
|
||||
"""Test malware detection callback with safe status."""
|
||||
|
||||
document = DocumentFactory(attachments=[safe_file])
|
||||
|
||||
malware_detection_callback(
|
||||
safe_file,
|
||||
ReportStatus.SAFE,
|
||||
error_info={},
|
||||
document_id=document.id,
|
||||
)
|
||||
|
||||
document.refresh_from_db()
|
||||
|
||||
assert safe_file in document.attachments
|
||||
assert default_storage.exists(safe_file)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
head_resp = s3_client.head_object(Bucket=bucket_name, Key=safe_file)
|
||||
metadata = head_resp.get("Metadata", {})
|
||||
assert metadata["status"] == DocumentAttachmentStatus.READY
|
||||
|
||||
|
||||
def test_malware_detection_callback_unsafe_status(unsafe_file):
|
||||
"""Test malware detection callback with unsafe status."""
|
||||
|
||||
document = DocumentFactory(attachments=[unsafe_file])
|
||||
|
||||
malware_detection_callback(
|
||||
unsafe_file,
|
||||
random.choice(
|
||||
[status.value for status in ReportStatus if status != ReportStatus.SAFE]
|
||||
),
|
||||
error_info={"error": "test", "error_code": 4001},
|
||||
document_id=document.id,
|
||||
)
|
||||
|
||||
document.refresh_from_db()
|
||||
|
||||
assert unsafe_file not in document.attachments
|
||||
assert not default_storage.exists(unsafe_file)
|
||||
@@ -56,5 +56,4 @@ urlpatterns = [
|
||||
),
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
path(f"api/{settings.API_VERSION}/footer/", viewsets.FooterView.as_view()),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Impress package. Import the celery app early to load shared task form dependencies."""
|
||||
|
||||
from .celery_app import app as celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
|
||||
@@ -11,6 +11,9 @@ os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
|
||||
|
||||
install(check_options=True)
|
||||
|
||||
# Can not be loaded only after install call.
|
||||
from django.conf import settings # pylint: disable=wrong-import-position
|
||||
|
||||
app = Celery("impress")
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
@@ -20,4 +23,4 @@ app = Celery("impress")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"footer": {
|
||||
"default": {
|
||||
"externalLinks": [
|
||||
{
|
||||
"label": "Github",
|
||||
"href": "https://github.com/suitenumerique/docs/"
|
||||
},
|
||||
{
|
||||
"label": "DINUM",
|
||||
"href": "https://www.numerique.gouv.fr/dinum/"
|
||||
},
|
||||
{
|
||||
"label": "ZenDiS",
|
||||
"href": "https://zendis.de/"
|
||||
},
|
||||
{
|
||||
"label": "BlockNote.js",
|
||||
"href": "https://www.blocknotejs.org/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Legal Notice",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Personal data and cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Mentions légales",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Données personnelles et cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Accessibilité",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Impressum",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Personenbezogene Daten und Cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Barrierefreiheit",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Wettelijke bepalingen",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Persoonlijke gegevens en cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Toegankelijkheid",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ class Base(Configuration):
|
||||
"django.contrib.staticfiles",
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
"lasuite.malware_detection",
|
||||
]
|
||||
|
||||
# Cache
|
||||
@@ -422,23 +423,22 @@ class Base(Configuration):
|
||||
environ_name="FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_URL_JSON_FOOTER = values.Value(
|
||||
None, environ_name="FRONTEND_URL_JSON_FOOTER", environ_prefix=None
|
||||
)
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="FRONTEND_FOOTER_FEATURE_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT = values.Value(
|
||||
60 * 60 * 24,
|
||||
environ_name="FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
FRONTEND_CSS_URL = values.Value(
|
||||
None, environ_name="FRONTEND_CSS_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
|
||||
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
|
||||
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.Value(
|
||||
60 * 60 * 24,
|
||||
environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Posthog
|
||||
POSTHOG_KEY = values.DictValue(
|
||||
None, environ_name="POSTHOG_KEY", environ_prefix=None
|
||||
@@ -462,9 +462,13 @@ class Base(Configuration):
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
|
||||
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
|
||||
)
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
@@ -549,6 +553,9 @@ class Base(Configuration):
|
||||
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_REDIRECT_FIELD_NAME = values.Value(
|
||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||
)
|
||||
|
||||
# WARNING: Enabling this setting allows multiple user accounts to share the same email
|
||||
# address. This may cause security issues and is not recommended for production use when
|
||||
@@ -680,6 +687,21 @@ class Base(Configuration):
|
||||
},
|
||||
}
|
||||
|
||||
MALWARE_DETECTION = {
|
||||
"BACKEND": values.Value(
|
||||
"lasuite.malware_detection.backends.dummy.DummyBackend",
|
||||
environ_name="MALWARE_DETECTION_BACKEND",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"PARAMETERS": values.DictValue(
|
||||
default={
|
||||
"callback_path": "core.malware_detection.malware_detection_callback",
|
||||
},
|
||||
environ_name="MALWARE_DETECTION_PARAMETERS",
|
||||
environ_prefix=None,
|
||||
),
|
||||
}
|
||||
|
||||
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
|
||||
default=5,
|
||||
environ_name="API_USERS_LIST_LIMIT",
|
||||
@@ -898,6 +920,11 @@ class Production(Base):
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
"KEY_PREFIX": values.Value(
|
||||
"docs",
|
||||
environ_name="CACHES_KEY_PREFIX",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -26,19 +26,19 @@ readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"beautifulsoup4==4.13.4",
|
||||
"boto3==1.38.9",
|
||||
"boto3==1.38.18",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.5.2",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-filter==25.1",
|
||||
"django-lasuite==0.0.7",
|
||||
"django-lasuite[all]==0.0.8",
|
||||
"django-parler==2.3",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.8",
|
||||
"django==5.1.9",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.0",
|
||||
"drf_spectacular==0.28.0",
|
||||
@@ -51,14 +51,14 @@ dependencies = [
|
||||
"markdown==3.8",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.77.0",
|
||||
"psycopg[binary]==3.2.7",
|
||||
"pycrdt==0.12.15",
|
||||
"openai==1.79.0",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"pycrdt==0.12.19",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.27.0",
|
||||
"sentry-sdk==2.28.0",
|
||||
"whitenoise==6.9.0",
|
||||
]
|
||||
|
||||
@@ -85,8 +85,8 @@ dev = [
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.11.8",
|
||||
"types-requests==2.32.0.20250328",
|
||||
"ruff==0.11.10",
|
||||
"types-requests==2.32.0.20250515",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
+22
-2
@@ -1,4 +1,9 @@
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:24-alpine AS frontend-deps
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -39,10 +44,25 @@ ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
|
||||
ARG SW_DEACTIVATED
|
||||
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
|
||||
|
||||
ARG PUBLISH_AS_MIT
|
||||
ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine AS frontend-production
|
||||
|
||||
# Remove the upgrade part once nginx has published
|
||||
# a new image that fixes the CVE related to libxml2
|
||||
ARG UID=101
|
||||
USER root
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
USER $UID
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100">
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="50" cy="30" r="20" fill="#3498db" />
|
||||
<polygon
|
||||
points="50,10 55,20 65,20 58,30 60,40 50,35 40,40 42,30 35,20 45,20"
|
||||
|
||||
|
Before Width: | Height: | Size: 292 B After Width: | Height: | Size: 336 B |
@@ -7,7 +7,6 @@ export const CONFIG = {
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_CSS_URL: null,
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
|
||||
FRONTEND_FOOTER_FEATURE_ENABLED: true,
|
||||
FRONTEND_THEME: 'default',
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
LANGUAGES: [
|
||||
@@ -20,6 +19,7 @@ export const CONFIG = {
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
POSTHOG_KEY: {},
|
||||
SENTRY_DSN: null,
|
||||
theme_customization: {},
|
||||
};
|
||||
|
||||
export const keyCloakSignIn = async (
|
||||
@@ -102,7 +102,7 @@ export const verifyDocName = async (page: Page, docName: string) => {
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
|
||||
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
|
||||
fillText: string = 'user ',
|
||||
) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
|
||||
@@ -4,6 +4,8 @@ import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
|
||||
import {
|
||||
CONFIG,
|
||||
addNewMember,
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
@@ -363,7 +365,7 @@ test.describe('Doc Editor', () => {
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
link_reach: 'public',
|
||||
link_reach: 'restricted',
|
||||
link_role: 'editor',
|
||||
created_at: '2021-09-01T09:00:00Z',
|
||||
title: '',
|
||||
@@ -452,4 +454,90 @@ test.describe('Doc Editor', () => {
|
||||
const svgBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
expect(svgBuffer.toString()).toContain('Hello svg');
|
||||
});
|
||||
|
||||
test('it checks block editing when not connected to collab server', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...CONFIG,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
void page
|
||||
.getByRole('button', {
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(
|
||||
card.getByText('Your network do not allow you to edit'),
|
||||
).toBeHidden();
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
await expect(editor).toHaveAttribute('contenteditable', 'true');
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await addNewMember(page, 0, 'Editor', 'impress');
|
||||
|
||||
// Close the modal
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
|
||||
await expect(
|
||||
card.getByText('Your network do not allow you to edit'),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(editor).toHaveAttribute('contenteditable', 'false');
|
||||
});
|
||||
|
||||
test('it checks if callout custom block', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Add a callout block').click();
|
||||
|
||||
const calloutBlock = page
|
||||
.locator('div[data-content-type="callout"]')
|
||||
.first();
|
||||
|
||||
await expect(calloutBlock).toBeVisible();
|
||||
|
||||
await calloutBlock.locator('.inline-content').fill('example text');
|
||||
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'yellow',
|
||||
);
|
||||
|
||||
const emojiButton = calloutBlock.getByRole('button');
|
||||
await expect(emojiButton).toHaveText('💡');
|
||||
await emojiButton.click();
|
||||
await page.locator('button[aria-label="⚠️"]').click();
|
||||
await expect(emojiButton).toHaveText('⚠️');
|
||||
|
||||
await page.locator('.bn-side-menu > button').last().click();
|
||||
await page.locator('.mantine-Menu-dropdown > button').last().click();
|
||||
await page.locator('.bn-color-picker-dropdown > button').last().click();
|
||||
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'pink',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,7 +141,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
/**
|
||||
* This test tell us that the export to pdf is working with images
|
||||
* but it does not tell us if the images are beeing displayed correctly
|
||||
* but it does not tell us if the images are being displayed correctly
|
||||
* in the pdf.
|
||||
*
|
||||
* TODO: Check if the images are displayed correctly in the pdf
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=true
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=true
|
||||
|
||||
@@ -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,
|
||||
...{
|
||||
|
||||
@@ -48,10 +48,7 @@ const nextConfig = {
|
||||
swDest: '../public/service-worker.js',
|
||||
include: [
|
||||
({ asset }) => {
|
||||
if (asset.name.match(/.*(static).*/)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return !!asset.name.match(/.*(static).*/);
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -15,40 +15,43 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.2",
|
||||
"@blocknote/code-block": "0.29.1",
|
||||
"@blocknote/core": "0.29.1",
|
||||
"@blocknote/mantine": "0.29.1",
|
||||
"@blocknote/react": "0.29.1",
|
||||
"@blocknote/xl-docx-exporter": "0.29.1",
|
||||
"@blocknote/xl-pdf-exporter": "0.29.1",
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@blocknote/code-block": "0.30.0",
|
||||
"@blocknote/core": "0.30.0",
|
||||
"@blocknote/mantine": "0.30.0",
|
||||
"@blocknote/react": "0.30.0",
|
||||
"@blocknote/xl-docx-exporter": "0.30.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.30.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.4.1",
|
||||
"@gouvfr-lasuite/ui-kit": "0.6.0",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "3.0.0",
|
||||
"@openfun/cunningham-react": "3.1.0",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@sentry/nextjs": "9.15.0",
|
||||
"@tanstack/react-query": "5.75.4",
|
||||
"@sentry/nextjs": "9.19.0",
|
||||
"@tanstack/react-query": "5.76.1",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.4.1",
|
||||
"i18next": "25.1.1",
|
||||
"docx": "9.5.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"i18next": "25.1.3",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"idb": "8.0.2",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.6.1",
|
||||
"next": "15.3.1",
|
||||
"posthog-js": "1.239.1",
|
||||
"next": "15.3.2",
|
||||
"posthog-js": "1.242.2",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.8.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.5.1",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-select": "5.10.1",
|
||||
"styled-components": "6.1.17",
|
||||
"styled-components": "6.1.18",
|
||||
"use-debounce": "10.0.4",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "*",
|
||||
@@ -56,7 +59,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.75.4",
|
||||
"@tanstack/react-query-devtools": "5.76.1",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.3.0",
|
||||
@@ -79,7 +82,7 @@
|
||||
"stylelint-config-standard": "38.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"webpack": "5.99.7",
|
||||
"webpack": "5.99.8",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
{
|
||||
"default": {
|
||||
"externalLinks": [
|
||||
{
|
||||
"label": "Github",
|
||||
"href": "https://github.com/suitenumerique/docs/"
|
||||
},
|
||||
{
|
||||
"label": "DINUM",
|
||||
"href": "https://www.numerique.gouv.fr/dinum/"
|
||||
},
|
||||
{
|
||||
"label": "ZenDiS",
|
||||
"href": "https://zendis.de/"
|
||||
},
|
||||
{
|
||||
"label": "BlockNote.js",
|
||||
"href": "https://www.blocknotejs.org/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Legal Notice",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Personal data and cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Mentions légales",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Données personnelles et cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Accessibilité",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Impressum",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Personenbezogene Daten und Cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Barrierefreiheit",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Wettelijke bepalingen",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Persoonlijke gegevens en cookies",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Toegankelijkheid",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
|
||||
"link": {
|
||||
"label": "licence MIT",
|
||||
"href": "https://github.com/suitenumerique/docs/blob/main/LICENSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
{
|
||||
"default": {
|
||||
"externalLinks": [
|
||||
{
|
||||
"label": "legifrance.gouv.fr",
|
||||
"href": "https://legifrance.gouv.fr/"
|
||||
},
|
||||
{
|
||||
"label": "info.gouv.fr",
|
||||
"href": "https://info.gouv.fr/"
|
||||
},
|
||||
{
|
||||
"label": "service-public.fr",
|
||||
"href": "https://service-public.fr/"
|
||||
},
|
||||
{
|
||||
"label": "data.gouv.fr",
|
||||
"href": "https://data.gouv.fr/"
|
||||
}
|
||||
],
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Legal Notice",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Personal data and cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Legal Notice",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Personal data and cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Accessibility",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Unless otherwise stated, all content on this site is under",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Mentions légales",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Données personnelles et cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Accessibilité",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sauf mention contraire, tout le contenu de ce site est sous",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Impressum",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Personenbezogene Daten und Cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Barrierefreiheit",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"legalLinks": [
|
||||
{
|
||||
"label": "Wettelijke bepalingen",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/4db744e3-5b47-4ed9-b9e7-d4312318bbce/"
|
||||
},
|
||||
{
|
||||
"label": "Persoonlijke gegevens en cookies",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/eb863389-a5e5-4d18-879d-149a1122380e/"
|
||||
},
|
||||
{
|
||||
"label": "Toegankelijkheid",
|
||||
"href": "https://docs.numerique.gouv.fr/docs/9694e570-1427-4ef7-b0a0-c3e894360e1b/"
|
||||
}
|
||||
],
|
||||
"bottomInformation": {
|
||||
"label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder",
|
||||
"link": {
|
||||
"label": "licence etalab-2.0",
|
||||
"href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,39 @@
|
||||
/**
|
||||
* Generic interface for representing an API error structure.
|
||||
*
|
||||
* @template T - Optional type of additional data returned with the error.
|
||||
*/
|
||||
interface IAPIError<T = unknown> {
|
||||
/** HTTP status code or API-defined error code */
|
||||
status: number;
|
||||
/** Optional list of error causes (e.g., validation issues) */
|
||||
cause?: string[];
|
||||
/** Optional extra data provided with the error */
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for representing API errors.
|
||||
* Extends the native Error object with additional context such as HTTP status,
|
||||
* causes, and extra data returned by the API.
|
||||
*
|
||||
* @template T - Optional type of the `data` field
|
||||
*/
|
||||
export class APIError<T = unknown> extends Error implements IAPIError<T> {
|
||||
public status: IAPIError['status'];
|
||||
public cause?: IAPIError['cause'];
|
||||
public data?: IAPIError<T>['data'];
|
||||
|
||||
/**
|
||||
* Constructs a new APIError instance.
|
||||
*
|
||||
* @param message - The human-readable error message.
|
||||
* @param status - The HTTP status code or equivalent.
|
||||
* @param cause - (Optional) List of strings describing error causes.
|
||||
* @param data - (Optional) Any additional data returned by the API.
|
||||
*/
|
||||
constructor(message: string, { status, cause, data }: IAPIError<T>) {
|
||||
super(message);
|
||||
|
||||
this.name = 'APIError';
|
||||
this.status = status;
|
||||
this.cause = cause;
|
||||
@@ -19,6 +41,12 @@ export class APIError<T = unknown> extends Error implements IAPIError<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for checking if a value is an instance of APIError.
|
||||
*
|
||||
* @param error - The value to check.
|
||||
* @returns True if the value is an instance of APIError.
|
||||
*/
|
||||
export const isAPIError = (error: unknown): error is APIError => {
|
||||
return error instanceof APIError;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { APIError, isAPIError } from '@/api';
|
||||
|
||||
describe('APIError', () => {
|
||||
it('should correctly instantiate with required fields', () => {
|
||||
const error = new APIError('Something went wrong', { status: 500 });
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(APIError);
|
||||
expect(error.message).toBe('Something went wrong');
|
||||
expect(error.status).toBe(500);
|
||||
expect(error.cause).toBeUndefined();
|
||||
expect(error.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should correctly instantiate with all fields', () => {
|
||||
const details = { field: 'email' };
|
||||
const error = new APIError('Validation failed', {
|
||||
status: 400,
|
||||
cause: ['Invalid email format'],
|
||||
data: details,
|
||||
});
|
||||
|
||||
expect(error.name).toBe('APIError');
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.cause).toEqual(['Invalid email format']);
|
||||
expect(error.data).toEqual(details);
|
||||
});
|
||||
|
||||
it('should be detected by isAPIError type guard', () => {
|
||||
const error = new APIError('Unauthorized', { status: 401 });
|
||||
const notAnError = { message: 'Fake error' };
|
||||
|
||||
expect(isAPIError(error)).toBe(true);
|
||||
expect(isAPIError(notAnError)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { baseApiUrl } from '@/api';
|
||||
|
||||
describe('config', () => {
|
||||
it('constructs URL with default version', () => {
|
||||
expect(baseApiUrl()).toBe('http://test.jest/api/v1.0/');
|
||||
});
|
||||
|
||||
it('constructs URL with custom version', () => {
|
||||
expect(baseApiUrl('2.0')).toBe('http://test.jest/api/v2.0/');
|
||||
});
|
||||
|
||||
it('uses env origin if available', () => {
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN = 'https://env.example.com';
|
||||
expect(baseApiUrl('3.0')).toBe('https://env.example.com/api/v3.0/');
|
||||
});
|
||||
});
|
||||
@@ -36,4 +36,13 @@ describe('fetchAPI', () => {
|
||||
|
||||
expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/v2.0/some/url');
|
||||
});
|
||||
|
||||
it('removes Content-Type header when withoutContentType is true', async () => {
|
||||
fetchMock.mock('http://test.jest/api/v1.0/some/url', 200);
|
||||
|
||||
await fetchAPI('some/url', { withoutContentType: true });
|
||||
|
||||
const options = fetchMock.lastOptions();
|
||||
expect(options?.headers).not.toHaveProperty('Content-Type');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useAPIInfiniteQuery } from '@/api';
|
||||
|
||||
interface DummyItem {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface DummyResponse {
|
||||
results: DummyItem[];
|
||||
next?: string;
|
||||
}
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient();
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('helpers', () => {
|
||||
it('fetches and paginates correctly', async () => {
|
||||
const mockAPI = jest
|
||||
.fn<Promise<DummyResponse>, [{ page: number; query: string }]>()
|
||||
.mockResolvedValueOnce({
|
||||
results: [{ id: 1 }],
|
||||
next: 'url?page=2',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
results: [{ id: 2 }],
|
||||
next: undefined,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAPIInfiniteQuery('test-key', mockAPI, { query: 'test' }),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Wait for first page
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.pages[0].results[0].id).toBe(1);
|
||||
});
|
||||
|
||||
// Fetch next page
|
||||
await result.current.fetchNextPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.pages.length).toBe(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.pages[1].results[0].id).toBe(2);
|
||||
});
|
||||
|
||||
expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 1 });
|
||||
expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { errorCauses, getCSRFToken } from '@/api';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('errorCauses', () => {
|
||||
const createMockResponse = (jsonData: any, status = 400): Response => {
|
||||
return {
|
||||
status,
|
||||
json: () => jsonData,
|
||||
} as unknown as Response;
|
||||
};
|
||||
|
||||
it('parses multiple string causes from error body', async () => {
|
||||
const mockResponse = createMockResponse(
|
||||
{
|
||||
field: ['error message 1', 'error message 2'],
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
const result = await errorCauses(mockResponse, { context: 'login' });
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.cause).toEqual(['error message 1', 'error message 2']);
|
||||
expect(result.data).toEqual({ context: 'login' });
|
||||
});
|
||||
|
||||
it('returns undefined causes if no JSON body', async () => {
|
||||
const mockResponse = createMockResponse(null, 500);
|
||||
|
||||
const result = await errorCauses(mockResponse);
|
||||
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.cause).toBeUndefined();
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCSRFToken', () => {
|
||||
it('extracts csrftoken from document.cookie', () => {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value: 'sessionid=xyz; csrftoken=abc123; theme=dark',
|
||||
});
|
||||
|
||||
expect(getCSRFToken()).toBe('abc123');
|
||||
});
|
||||
|
||||
it('returns undefined if csrftoken is not present', () => {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value: 'sessionid=xyz; theme=dark',
|
||||
});
|
||||
|
||||
expect(getCSRFToken()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,22 @@
|
||||
/**
|
||||
* Returns the base URL for the backend API.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Uses NEXT_PUBLIC_API_ORIGIN from environment variables if defined.
|
||||
* 2. Falls back to the browser's window.location.origin if in a browser environment.
|
||||
* 3. Defaults to an empty string if executed in a non-browser environment without the env variable.
|
||||
*
|
||||
* @returns The backend base URL as a string.
|
||||
*/
|
||||
export const backendUrl = () =>
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
|
||||
/**
|
||||
* Constructs the full base API URL, including the versioned path (e.g., `/api/v1.0/`).
|
||||
*
|
||||
* @param apiVersion - The version of the API (defaults to '1.0').
|
||||
* @returns The full versioned API base URL as a string.
|
||||
*/
|
||||
export const baseApiUrl = (apiVersion: string = '1.0') =>
|
||||
`${backendUrl()}/api/v${apiVersion}/`;
|
||||
|
||||
@@ -23,11 +23,9 @@ export const fetchAPI = async (
|
||||
delete headers?.['Content-Type' as keyof typeof headers];
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
return await fetch(apiUrl, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -22,9 +22,17 @@ export type DefinedInitialDataInfiniteOptionsAPI<
|
||||
>;
|
||||
|
||||
/**
|
||||
* @param param Used for infinite scroll pagination
|
||||
* @param queryConfig
|
||||
* @returns
|
||||
* Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests.
|
||||
*
|
||||
* @template T - Type of the request parameters.
|
||||
* @template Q - Type of the API response, which must include an optional `next` field for pagination.
|
||||
*
|
||||
* @param {string} key - Unique key to identify the query in the cache.
|
||||
* @param {(props: T & { page: number }) => Promise<Q>} api - Function that fetches paginated data from the API. It receives the params merged with a page number.
|
||||
* @param {T} param - Static parameters to send with every API request (excluding the page number).
|
||||
* @param {DefinedInitialDataInfiniteOptionsAPI<Q>} [queryConfig] - Optional configuration passed to `useInfiniteQuery` (e.g., stale time, cache time).
|
||||
*
|
||||
* @returns Return value of `useInfiniteQuery`, including data, loading state, fetchNextPage, etc.
|
||||
*/
|
||||
export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
|
||||
key: string,
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
/**
|
||||
* Generic interface representing a paginated API response.
|
||||
*
|
||||
* Commonly used for endpoints that return list results with pagination metadata.
|
||||
*
|
||||
* @template T - The type of items in the `results` array.
|
||||
*/
|
||||
export interface APIList<T> {
|
||||
/** Total number of items across all pages */
|
||||
count: number;
|
||||
|
||||
/** URL to the next page of results, if available (can be null or undefined) */
|
||||
next?: string | null;
|
||||
|
||||
/** URL to the previous page of results, if available (can be null or undefined) */
|
||||
previous?: string | null;
|
||||
|
||||
/** The list of items for the current page */
|
||||
results: T[];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Extracts error information from an HTTP `Response` object.
|
||||
*
|
||||
* This is typically used to parse structured error responses from an API
|
||||
* and normalize them into a consistent format with `status`, `cause`, and optional `data`.
|
||||
*
|
||||
* @param response - The HTTP response object from `fetch()`.
|
||||
* @param data - Optional custom data to include with the error output.
|
||||
* @returns An object containing:
|
||||
* - `status`: HTTP status code from the response
|
||||
* - `cause`: A flattened list of error messages, or undefined if no body
|
||||
* - `data`: The optional data passed in
|
||||
*/
|
||||
export const errorCauses = async (response: Response, data?: unknown) => {
|
||||
const errorsBody = (await response.json()) as Record<
|
||||
string,
|
||||
@@ -18,7 +31,11 @@ export const errorCauses = async (response: Response, data?: unknown) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the CSRF token from the document's cookies.
|
||||
* Retrieves the CSRF token from the browser's cookies.
|
||||
*
|
||||
* Assumes the CSRF token is stored as a cookie named "csrftoken".
|
||||
*
|
||||
* @returns The CSRF token string if found, otherwise `undefined`.
|
||||
*/
|
||||
export function getCSRFToken() {
|
||||
return document.cookie
|
||||
|
||||
@@ -10,7 +10,7 @@ type TextSizes = keyof typeof sizes;
|
||||
|
||||
export interface TextProps extends BoxProps {
|
||||
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
$elipsis?: boolean;
|
||||
$ellipsis?: boolean;
|
||||
$weight?: CSSProperties['fontWeight'];
|
||||
$textAlign?: CSSProperties['textAlign'];
|
||||
$size?: TextSizes | (string & {});
|
||||
@@ -50,8 +50,8 @@ export const TextStyled = styled(Box)<TextProps>`
|
||||
${({ $theme, $variation }) =>
|
||||
`color: var(--c--theme--colors--${$theme}-${$variation});`}
|
||||
${({ $color }) => $color && `color: ${$color};`}
|
||||
${({ $elipsis }) =>
|
||||
$elipsis &&
|
||||
${({ $ellipsis }) =>
|
||||
$ellipsis &&
|
||||
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||
import { Auth, KEY_AUTH } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { ConfigProvider } from './config/';
|
||||
|
||||
export const DEFAULT_QUERY_RETRY = 1;
|
||||
|
||||
/**
|
||||
* QueryClient:
|
||||
* - defaultOptions:
|
||||
@@ -19,7 +21,7 @@ import { ConfigProvider } from './config/';
|
||||
const defaultOptions = {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 3,
|
||||
retry: 1,
|
||||
retry: DEFAULT_QUERY_RETRY,
|
||||
},
|
||||
};
|
||||
const queryClient = new QueryClient({
|
||||
@@ -35,8 +37,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanupResizeListener = initializeResizeListener();
|
||||
return cleanupResizeListener;
|
||||
return initializeResizeListener();
|
||||
}, [initializeResizeListener]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,8 +53,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_AUTH],
|
||||
});
|
||||
setAuthUrl();
|
||||
void replace(`/401`);
|
||||
void replace(
|
||||
`/401?returnTo=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -38,7 +38,10 @@ export const getConfig = async (): Promise<ConfigResponse> => {
|
||||
const response = await fetchAPI(`config/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the doc', await errorCauses(response));
|
||||
throw new APIError(
|
||||
'Failed to get the configurations',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
const config = response.json() as Promise<ConfigResponse>;
|
||||
@@ -53,13 +56,11 @@ export function useConfig() {
|
||||
const cachedData = getCachedTranslation();
|
||||
const oneHour = 1000 * 60 * 60;
|
||||
|
||||
const response = useQuery<ConfigResponse, APIError, ConfigResponse>({
|
||||
return useQuery<ConfigResponse, APIError, ConfigResponse>({
|
||||
queryKey: [KEY_CONFIG],
|
||||
queryFn: () => getConfig(),
|
||||
initialData: cachedData,
|
||||
staleTime: oneHour,
|
||||
initialDataUpdatedAt: Date.now() - oneHour, // Force initial data to be considered stale
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -78,37 +78,40 @@
|
||||
--c--theme--colors--greyscale-750: #353535;
|
||||
--c--theme--colors--greyscale-950: #1e1e1e;
|
||||
--c--theme--colors--greyscale-1000: #161616;
|
||||
--c--theme--colors--danger-050: #fff4f4;
|
||||
--c--theme--colors--blue-500: #417dc4;
|
||||
--c--theme--colors--brown-500: #bd987a;
|
||||
--c--theme--colors--cyan-500: #009099;
|
||||
--c--theme--colors--gold-500: #c3992a;
|
||||
--c--theme--colors--green-500: #00a95f;
|
||||
--c--theme--colors--olive-500: #68a532;
|
||||
--c--theme--colors--orange-500: #e4794a;
|
||||
--c--theme--colors--purple-500: #a558a0;
|
||||
--c--theme--colors--red-500: #e1000f;
|
||||
--c--theme--colors--yellow-500: #b7a73f;
|
||||
--c--theme--colors--rose-500: #e18b76;
|
||||
--c--theme--colors--primary-action: #1212ff;
|
||||
--c--theme--colors--primary-bg: #fafafa;
|
||||
--c--theme--colors--blue-400: #7ab1e8;
|
||||
--c--theme--colors--blue-500: #417dc4;
|
||||
--c--theme--colors--blue-600: #3558a2;
|
||||
--c--theme--colors--brown-400: #e6be92;
|
||||
--c--theme--colors--brown-500: #bd987a;
|
||||
--c--theme--colors--brown-600: #745b47;
|
||||
--c--theme--colors--cyan-400: #34bab5;
|
||||
--c--theme--colors--cyan-500: #009099;
|
||||
--c--theme--colors--cyan-600: #006a6f;
|
||||
--c--theme--colors--gold-400: #ffca00;
|
||||
--c--theme--colors--gold-500: #c3992a;
|
||||
--c--theme--colors--gold-600: #695240;
|
||||
--c--theme--colors--green-400: #34cb6a;
|
||||
--c--theme--colors--green-500: #00a95f;
|
||||
--c--theme--colors--green-600: #297254;
|
||||
--c--theme--colors--olive-400: #99c221;
|
||||
--c--theme--colors--olive-500: #68a532;
|
||||
--c--theme--colors--olive-600: #447049;
|
||||
--c--theme--colors--orange-400: #ff732c;
|
||||
--c--theme--colors--orange-500: #e4794a;
|
||||
--c--theme--colors--orange-600: #755348;
|
||||
--c--theme--colors--pink-400: #ffb7ae;
|
||||
--c--theme--colors--pink-500: #e18b76;
|
||||
--c--theme--colors--pink-600: #8d533e;
|
||||
--c--theme--colors--purple-400: #ce70cc;
|
||||
--c--theme--colors--purple-500: #a558a0;
|
||||
--c--theme--colors--purple-600: #6e445a;
|
||||
--c--theme--colors--yellow-400: #d8c634;
|
||||
--c--theme--colors--yellow-500: #b7a73f;
|
||||
--c--theme--colors--yellow-600: #66673d;
|
||||
--c--theme--font--sizes--h1: 2rem;
|
||||
--c--theme--font--sizes--h2: 1.75rem;
|
||||
@@ -192,6 +195,10 @@
|
||||
--c--theme--logo--widthfooter: 220px;
|
||||
--c--theme--logo--alt: gouvernement logo;
|
||||
--c--components--modal--width-small: 342px;
|
||||
--c--components--tooltip--padding: 4px 8px;
|
||||
--c--components--tooltip--background-color: var(
|
||||
--c--theme--colors--greyscale-1000
|
||||
);
|
||||
--c--components--button--medium-height: 40px;
|
||||
--c--components--button--medium-text-height: 40px;
|
||||
--c--components--button--border-radius: 4px;
|
||||
@@ -448,6 +455,36 @@
|
||||
--c--theme--colors--greyscale-950
|
||||
);
|
||||
--c--components--forms-select--font-size: 14px;
|
||||
--c--components--badge--font-size: var(--c--theme--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 4px;
|
||||
--c--components--badge--padding-inline: var(--c--theme--spacings--xs);
|
||||
--c--components--badge--padding-block: var(--c--theme--spacings--2xs);
|
||||
--c--components--badge--accent--background-color: var(
|
||||
--c--theme--colors--primary-100
|
||||
);
|
||||
--c--components--badge--accent--color: var(--c--theme--colors--primary-600);
|
||||
--c--components--badge--neutral--background-color: var(
|
||||
--c--theme--colors--greyscale-100
|
||||
);
|
||||
--c--components--badge--neutral--color: var(
|
||||
--c--theme--colors--greyscale-600
|
||||
);
|
||||
--c--components--badge--danger--background-color: var(
|
||||
--c--theme--colors--danger-100
|
||||
);
|
||||
--c--components--badge--danger--color: var(--c--theme--colors--danger-600);
|
||||
--c--components--badge--success--background-color: var(
|
||||
--c--theme--colors--success-100
|
||||
);
|
||||
--c--components--badge--success--color: var(--c--theme--colors--success-600);
|
||||
--c--components--badge--warning--background-color: var(
|
||||
--c--theme--colors--warning-100
|
||||
);
|
||||
--c--components--badge--warning--color: var(--c--theme--colors--warning-600);
|
||||
--c--components--badge--info--background-color: var(
|
||||
--c--theme--colors--info-100
|
||||
);
|
||||
--c--components--badge--info--color: var(--c--theme--colors--info-600);
|
||||
--c--components--la-gauffre--activated: true;
|
||||
--c--components--home-proconnect--activated: true;
|
||||
}
|
||||
@@ -817,6 +854,54 @@
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
}
|
||||
|
||||
.clr-danger-050 {
|
||||
color: var(--c--theme--colors--danger-050);
|
||||
}
|
||||
|
||||
.clr-blue-500 {
|
||||
color: var(--c--theme--colors--blue-500);
|
||||
}
|
||||
|
||||
.clr-brown-500 {
|
||||
color: var(--c--theme--colors--brown-500);
|
||||
}
|
||||
|
||||
.clr-cyan-500 {
|
||||
color: var(--c--theme--colors--cyan-500);
|
||||
}
|
||||
|
||||
.clr-gold-500 {
|
||||
color: var(--c--theme--colors--gold-500);
|
||||
}
|
||||
|
||||
.clr-green-500 {
|
||||
color: var(--c--theme--colors--green-500);
|
||||
}
|
||||
|
||||
.clr-olive-500 {
|
||||
color: var(--c--theme--colors--olive-500);
|
||||
}
|
||||
|
||||
.clr-orange-500 {
|
||||
color: var(--c--theme--colors--orange-500);
|
||||
}
|
||||
|
||||
.clr-purple-500 {
|
||||
color: var(--c--theme--colors--purple-500);
|
||||
}
|
||||
|
||||
.clr-red-500 {
|
||||
color: var(--c--theme--colors--red-500);
|
||||
}
|
||||
|
||||
.clr-yellow-500 {
|
||||
color: var(--c--theme--colors--yellow-500);
|
||||
}
|
||||
|
||||
.clr-rose-500 {
|
||||
color: var(--c--theme--colors--rose-500);
|
||||
}
|
||||
|
||||
.clr-primary-action {
|
||||
color: var(--c--theme--colors--primary-action);
|
||||
}
|
||||
@@ -829,10 +914,6 @@
|
||||
color: var(--c--theme--colors--blue-400);
|
||||
}
|
||||
|
||||
.clr-blue-500 {
|
||||
color: var(--c--theme--colors--blue-500);
|
||||
}
|
||||
|
||||
.clr-blue-600 {
|
||||
color: var(--c--theme--colors--blue-600);
|
||||
}
|
||||
@@ -841,10 +922,6 @@
|
||||
color: var(--c--theme--colors--brown-400);
|
||||
}
|
||||
|
||||
.clr-brown-500 {
|
||||
color: var(--c--theme--colors--brown-500);
|
||||
}
|
||||
|
||||
.clr-brown-600 {
|
||||
color: var(--c--theme--colors--brown-600);
|
||||
}
|
||||
@@ -853,10 +930,6 @@
|
||||
color: var(--c--theme--colors--cyan-400);
|
||||
}
|
||||
|
||||
.clr-cyan-500 {
|
||||
color: var(--c--theme--colors--cyan-500);
|
||||
}
|
||||
|
||||
.clr-cyan-600 {
|
||||
color: var(--c--theme--colors--cyan-600);
|
||||
}
|
||||
@@ -865,10 +938,6 @@
|
||||
color: var(--c--theme--colors--gold-400);
|
||||
}
|
||||
|
||||
.clr-gold-500 {
|
||||
color: var(--c--theme--colors--gold-500);
|
||||
}
|
||||
|
||||
.clr-gold-600 {
|
||||
color: var(--c--theme--colors--gold-600);
|
||||
}
|
||||
@@ -877,10 +946,6 @@
|
||||
color: var(--c--theme--colors--green-400);
|
||||
}
|
||||
|
||||
.clr-green-500 {
|
||||
color: var(--c--theme--colors--green-500);
|
||||
}
|
||||
|
||||
.clr-green-600 {
|
||||
color: var(--c--theme--colors--green-600);
|
||||
}
|
||||
@@ -889,10 +954,6 @@
|
||||
color: var(--c--theme--colors--olive-400);
|
||||
}
|
||||
|
||||
.clr-olive-500 {
|
||||
color: var(--c--theme--colors--olive-500);
|
||||
}
|
||||
|
||||
.clr-olive-600 {
|
||||
color: var(--c--theme--colors--olive-600);
|
||||
}
|
||||
@@ -901,10 +962,6 @@
|
||||
color: var(--c--theme--colors--orange-400);
|
||||
}
|
||||
|
||||
.clr-orange-500 {
|
||||
color: var(--c--theme--colors--orange-500);
|
||||
}
|
||||
|
||||
.clr-orange-600 {
|
||||
color: var(--c--theme--colors--orange-600);
|
||||
}
|
||||
@@ -925,10 +982,6 @@
|
||||
color: var(--c--theme--colors--purple-400);
|
||||
}
|
||||
|
||||
.clr-purple-500 {
|
||||
color: var(--c--theme--colors--purple-500);
|
||||
}
|
||||
|
||||
.clr-purple-600 {
|
||||
color: var(--c--theme--colors--purple-600);
|
||||
}
|
||||
@@ -937,10 +990,6 @@
|
||||
color: var(--c--theme--colors--yellow-400);
|
||||
}
|
||||
|
||||
.clr-yellow-500 {
|
||||
color: var(--c--theme--colors--yellow-500);
|
||||
}
|
||||
|
||||
.clr-yellow-600 {
|
||||
color: var(--c--theme--colors--yellow-600);
|
||||
}
|
||||
@@ -1261,6 +1310,54 @@
|
||||
background-color: var(--c--theme--colors--greyscale-1000);
|
||||
}
|
||||
|
||||
.bg-danger-050 {
|
||||
background-color: var(--c--theme--colors--danger-050);
|
||||
}
|
||||
|
||||
.bg-blue-500 {
|
||||
background-color: var(--c--theme--colors--blue-500);
|
||||
}
|
||||
|
||||
.bg-brown-500 {
|
||||
background-color: var(--c--theme--colors--brown-500);
|
||||
}
|
||||
|
||||
.bg-cyan-500 {
|
||||
background-color: var(--c--theme--colors--cyan-500);
|
||||
}
|
||||
|
||||
.bg-gold-500 {
|
||||
background-color: var(--c--theme--colors--gold-500);
|
||||
}
|
||||
|
||||
.bg-green-500 {
|
||||
background-color: var(--c--theme--colors--green-500);
|
||||
}
|
||||
|
||||
.bg-olive-500 {
|
||||
background-color: var(--c--theme--colors--olive-500);
|
||||
}
|
||||
|
||||
.bg-orange-500 {
|
||||
background-color: var(--c--theme--colors--orange-500);
|
||||
}
|
||||
|
||||
.bg-purple-500 {
|
||||
background-color: var(--c--theme--colors--purple-500);
|
||||
}
|
||||
|
||||
.bg-red-500 {
|
||||
background-color: var(--c--theme--colors--red-500);
|
||||
}
|
||||
|
||||
.bg-yellow-500 {
|
||||
background-color: var(--c--theme--colors--yellow-500);
|
||||
}
|
||||
|
||||
.bg-rose-500 {
|
||||
background-color: var(--c--theme--colors--rose-500);
|
||||
}
|
||||
|
||||
.bg-primary-action {
|
||||
background-color: var(--c--theme--colors--primary-action);
|
||||
}
|
||||
@@ -1273,10 +1370,6 @@
|
||||
background-color: var(--c--theme--colors--blue-400);
|
||||
}
|
||||
|
||||
.bg-blue-500 {
|
||||
background-color: var(--c--theme--colors--blue-500);
|
||||
}
|
||||
|
||||
.bg-blue-600 {
|
||||
background-color: var(--c--theme--colors--blue-600);
|
||||
}
|
||||
@@ -1285,10 +1378,6 @@
|
||||
background-color: var(--c--theme--colors--brown-400);
|
||||
}
|
||||
|
||||
.bg-brown-500 {
|
||||
background-color: var(--c--theme--colors--brown-500);
|
||||
}
|
||||
|
||||
.bg-brown-600 {
|
||||
background-color: var(--c--theme--colors--brown-600);
|
||||
}
|
||||
@@ -1297,10 +1386,6 @@
|
||||
background-color: var(--c--theme--colors--cyan-400);
|
||||
}
|
||||
|
||||
.bg-cyan-500 {
|
||||
background-color: var(--c--theme--colors--cyan-500);
|
||||
}
|
||||
|
||||
.bg-cyan-600 {
|
||||
background-color: var(--c--theme--colors--cyan-600);
|
||||
}
|
||||
@@ -1309,10 +1394,6 @@
|
||||
background-color: var(--c--theme--colors--gold-400);
|
||||
}
|
||||
|
||||
.bg-gold-500 {
|
||||
background-color: var(--c--theme--colors--gold-500);
|
||||
}
|
||||
|
||||
.bg-gold-600 {
|
||||
background-color: var(--c--theme--colors--gold-600);
|
||||
}
|
||||
@@ -1321,10 +1402,6 @@
|
||||
background-color: var(--c--theme--colors--green-400);
|
||||
}
|
||||
|
||||
.bg-green-500 {
|
||||
background-color: var(--c--theme--colors--green-500);
|
||||
}
|
||||
|
||||
.bg-green-600 {
|
||||
background-color: var(--c--theme--colors--green-600);
|
||||
}
|
||||
@@ -1333,10 +1410,6 @@
|
||||
background-color: var(--c--theme--colors--olive-400);
|
||||
}
|
||||
|
||||
.bg-olive-500 {
|
||||
background-color: var(--c--theme--colors--olive-500);
|
||||
}
|
||||
|
||||
.bg-olive-600 {
|
||||
background-color: var(--c--theme--colors--olive-600);
|
||||
}
|
||||
@@ -1345,10 +1418,6 @@
|
||||
background-color: var(--c--theme--colors--orange-400);
|
||||
}
|
||||
|
||||
.bg-orange-500 {
|
||||
background-color: var(--c--theme--colors--orange-500);
|
||||
}
|
||||
|
||||
.bg-orange-600 {
|
||||
background-color: var(--c--theme--colors--orange-600);
|
||||
}
|
||||
@@ -1369,10 +1438,6 @@
|
||||
background-color: var(--c--theme--colors--purple-400);
|
||||
}
|
||||
|
||||
.bg-purple-500 {
|
||||
background-color: var(--c--theme--colors--purple-500);
|
||||
}
|
||||
|
||||
.bg-purple-600 {
|
||||
background-color: var(--c--theme--colors--purple-600);
|
||||
}
|
||||
@@ -1381,10 +1446,6 @@
|
||||
background-color: var(--c--theme--colors--yellow-400);
|
||||
}
|
||||
|
||||
.bg-yellow-500 {
|
||||
background-color: var(--c--theme--colors--yellow-500);
|
||||
}
|
||||
|
||||
.bg-yellow-600 {
|
||||
background-color: var(--c--theme--colors--yellow-600);
|
||||
}
|
||||
|
||||
@@ -82,37 +82,40 @@ export const tokens = {
|
||||
'greyscale-750': '#353535',
|
||||
'greyscale-950': '#1E1E1E',
|
||||
'greyscale-1000': '#161616',
|
||||
'danger-050': '#FFF4F4',
|
||||
'blue-500': '#417DC4',
|
||||
'brown-500': '#BD987A',
|
||||
'cyan-500': '#009099',
|
||||
'gold-500': '#C3992A',
|
||||
'green-500': '#00A95F',
|
||||
'olive-500': '#68A532',
|
||||
'orange-500': '#E4794A',
|
||||
'purple-500': '#A558A0',
|
||||
'red-500': '#E1000F',
|
||||
'yellow-500': '#B7A73F',
|
||||
'rose-500': '#E18B76',
|
||||
'primary-action': '#1212FF',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'blue-400': '#7AB1E8',
|
||||
'blue-500': '#417DC4',
|
||||
'blue-600': '#3558A2',
|
||||
'brown-400': '#E6BE92',
|
||||
'brown-500': '#BD987A',
|
||||
'brown-600': '#745B47',
|
||||
'cyan-400': '#34BAB5',
|
||||
'cyan-500': '#009099',
|
||||
'cyan-600': '#006A6F',
|
||||
'gold-400': '#FFCA00',
|
||||
'gold-500': '#C3992A',
|
||||
'gold-600': '#695240',
|
||||
'green-400': '#34CB6A',
|
||||
'green-500': '#00A95F',
|
||||
'green-600': '#297254',
|
||||
'olive-400': '#99C221',
|
||||
'olive-500': '#68A532',
|
||||
'olive-600': '#447049',
|
||||
'orange-400': '#FF732C',
|
||||
'orange-500': '#E4794A',
|
||||
'orange-600': '#755348',
|
||||
'pink-400': '#FFB7AE',
|
||||
'pink-500': '#E18B76',
|
||||
'pink-600': '#8D533E',
|
||||
'purple-400': '#CE70CC',
|
||||
'purple-500': '#A558A0',
|
||||
'purple-600': '#6E445A',
|
||||
'yellow-400': '#D8C634',
|
||||
'yellow-500': '#B7A73F',
|
||||
'yellow-600': '#66673D',
|
||||
},
|
||||
font: {
|
||||
@@ -214,70 +217,65 @@ export const tokens = {
|
||||
},
|
||||
components: {
|
||||
modal: { 'width-small': '342px' },
|
||||
tooltip: { padding: '4px 8px', 'background-color': '#161616' },
|
||||
button: {
|
||||
'medium-height': '40px',
|
||||
'medium-text-height': '40px',
|
||||
'border-radius': '4px',
|
||||
'small-height': '26px',
|
||||
primary: {
|
||||
'background--color': 'var(--c--theme--colors--primary-text)',
|
||||
'background--color': '#000091',
|
||||
'background--color-hover': '#1212ff',
|
||||
'background--color-active': '#2323ff',
|
||||
'background--color-disabled':
|
||||
'var(--c--theme--colors--greyscale-100)',
|
||||
'background--color-disabled': '#eee',
|
||||
color: '#fff',
|
||||
'color-hover': '#fff',
|
||||
'color-active': '#fff',
|
||||
'color-focus-visible': '#fff',
|
||||
disabled: 'var(--c--theme--colors--greyscale-500)',
|
||||
disabled: '#7C7C7C',
|
||||
},
|
||||
'primary-text': {
|
||||
'background--color': 'var(--c--theme--colors--primary-text)',
|
||||
'background--color-hover': 'var(--c--theme--colors--greyscale-100)',
|
||||
'background--color-active': 'var(--c--theme--colors--primary-100)',
|
||||
'background--color': '#000091',
|
||||
'background--color-hover': '#eee',
|
||||
'background--color-active': '#ECECFE',
|
||||
'background--color-focus-visible': '#fff',
|
||||
'background--color-disabled':
|
||||
'var(--c--theme--colors--greyscale-000)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-800)',
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
'background--color-disabled': '#fff',
|
||||
color: '#000091',
|
||||
'color-hover': '#000091',
|
||||
disabled: '#929292',
|
||||
},
|
||||
secondary: {
|
||||
'background--color-hover': '#F6F6F6',
|
||||
'background--color-active': '#EDEDED',
|
||||
'background--color-focus-visible':
|
||||
'var(--c--theme--colors--greyscale-000)',
|
||||
'background--disabled': 'var(--c--theme--colors--greyscale-000)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
'border--color': 'var(--c--theme--colors--greyscale-300)',
|
||||
'border--color-hover': 'var(--c--theme--colors--greyscale-300)',
|
||||
'border--color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
'background--color-focus-visible': '#fff',
|
||||
'background--disabled': '#fff',
|
||||
color: '#000091',
|
||||
'border--color': '#CECECE',
|
||||
'border--color-hover': '#CECECE',
|
||||
'border--color-disabled': '#CECECE',
|
||||
disabled: '#929292',
|
||||
},
|
||||
tertiary: {
|
||||
'background--color': 'var(--c--theme--colors--primary-100)',
|
||||
'background--color-focus-visible':
|
||||
'var(--c--theme--colors--primary-100)',
|
||||
'background--color-hover': 'var(--c--theme--colors--primary-300)',
|
||||
'background--color-active': 'var(--c--theme--colors--primary-300)',
|
||||
'background--disabled': 'var(--c--theme--colors--primary-050)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
disabled: 'var(--c--theme--colors--primary-300)',
|
||||
'background--color': '#ECECFE',
|
||||
'background--color-focus-visible': '#ECECFE',
|
||||
'background--color-hover': '#CACAFB',
|
||||
'background--color-active': '#CACAFB',
|
||||
'background--disabled': '#F5F5FE',
|
||||
color: '#000091',
|
||||
disabled: '#CACAFB',
|
||||
},
|
||||
'tertiary-text': {
|
||||
'background--color-hover': 'var(--c--theme--colors--greyscale-100)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
'background--color-hover': '#eee',
|
||||
'color-hover': '#000091',
|
||||
color: '#313178',
|
||||
},
|
||||
danger: {
|
||||
'color-hover': 'white',
|
||||
'background--color': 'var(--c--theme--colors--danger-600)',
|
||||
'background--color': '#CE0500',
|
||||
'background--color-hover': '#FF2725',
|
||||
'background--color-focus-visible':
|
||||
'var(--c--theme--colors--danger-600)',
|
||||
'background--color-disabled':
|
||||
'var(--c--theme--colors--greyscale-100)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-400)',
|
||||
'background--color-focus-visible': '#CE0500',
|
||||
'background--color-disabled': '#eee',
|
||||
'color-disabled': '#929292',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
@@ -288,22 +286,22 @@ export const tokens = {
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'border-radius': '4px',
|
||||
'border-color': 'var(--c--theme--colors--primary-800)',
|
||||
'background-color--hover': 'var(--c--theme--colors--greyscale-100)',
|
||||
'border--color-disabled': 'var(--c--theme--colors--greyscale-200)',
|
||||
'border--color': 'var(--c--theme--colors--primary-800)',
|
||||
'background--disabled': 'var(--c--theme--colors--greyscale-200)',
|
||||
'background--enable': 'var(--c--theme--colors--primary-800)',
|
||||
'check--disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'check--enable': 'var(--c--theme--colors--greyscale-000)',
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'label--color': 'var(--c--theme--colors--greyscale-1000)',
|
||||
'label--size': 'var(--c--theme--font--sizes--sm)',
|
||||
'border-color': '#000091',
|
||||
'background-color--hover': '#eee',
|
||||
'border--color-disabled': '#E5E5E5',
|
||||
'border--color': '#000091',
|
||||
'background--disabled': '#E5E5E5',
|
||||
'background--enable': '#000091',
|
||||
'check--disabled': '#CECECE',
|
||||
'check--enable': '#fff',
|
||||
color: '#000091',
|
||||
'label--color': '#161616',
|
||||
'label--size': '0.875rem',
|
||||
'label--weight': '500',
|
||||
'text--color': 'var(--c--theme--colors--greyscale-600)',
|
||||
'text--size': 'var(--c--theme--font--sizes--s)',
|
||||
'text--color': '#666666',
|
||||
'text--size': '0.75rem',
|
||||
'text--weight': '400',
|
||||
'text--color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'text--color-disabled': '#CECECE',
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color--small': '#1E1E1E',
|
||||
@@ -312,20 +310,18 @@ export const tokens = {
|
||||
'label-color--big--disabled': '#CECECE',
|
||||
},
|
||||
'forms-radio': {
|
||||
'border-color': 'var(--c--theme--colors--primary-800)',
|
||||
'background-color': 'var(--c--theme--colors--greyscale-000)',
|
||||
'accent-color': 'var(--c--theme--colors--primary-800)',
|
||||
'accent-color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'border-color': '#000091',
|
||||
'background-color': '#fff',
|
||||
'accent-color': '#000091',
|
||||
'accent-color-disabled': '#CECECE',
|
||||
},
|
||||
'forms-switch': {
|
||||
'border--color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'border--color': 'var(--c--theme--colors--primary-800)',
|
||||
'border--color-disabled': '#CECECE',
|
||||
'border--color': '#000091',
|
||||
'handle-background-color': 'white',
|
||||
'handle-background-color--disabled':
|
||||
'var(--c--theme--colors--greyscale-000)',
|
||||
'rail-background-color--disabled':
|
||||
'var(--c--theme--colors--greyscale-000)',
|
||||
'accent-color': 'var(--c--theme--colors--primary-800)',
|
||||
'handle-background-color--disabled': '#fff',
|
||||
'rail-background-color--disabled': '#fff',
|
||||
'accent-color': '#000091',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'label-color--focus': '#161616',
|
||||
@@ -358,6 +354,18 @@ export const tokens = {
|
||||
'value-color': '#1E1E1E',
|
||||
'font-size': '14px',
|
||||
},
|
||||
badge: {
|
||||
'font-size': '0.75rem',
|
||||
'border-radius': '4px',
|
||||
'padding-inline': '0.5rem',
|
||||
'padding-block': '0.375rem',
|
||||
accent: { 'background-color': '#ECECFE', color: '#313178' },
|
||||
neutral: { 'background-color': '#eee', color: '#666666' },
|
||||
danger: { 'background-color': '#FFE9E9', color: '#CE0500' },
|
||||
success: { 'background-color': '#dffee6', color: '#18753c' },
|
||||
warning: { 'background-color': '#fff4f3', color: '#b34000' },
|
||||
info: { 'background-color': '#E8EDFF', color: '#0063CB' },
|
||||
},
|
||||
'la-gauffre': { activated: true },
|
||||
'home-proconnect': { activated: true },
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ declare module '*.svg?url' {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NEXT_PUBLIC_API_ORIGIN?: string;
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT?: string;
|
||||
NEXT_PUBLIC_SW_DEACTIVATED?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { DEFAULT_QUERY_RETRY } from '@/core';
|
||||
import {
|
||||
attemptSilentLogin,
|
||||
canAttemptSilentLogin,
|
||||
} from '@/features/auth/silentLogin';
|
||||
|
||||
import { User } from './types';
|
||||
|
||||
@@ -16,6 +21,16 @@ import { User } from './types';
|
||||
*/
|
||||
export const getMe = async (): Promise<User> => {
|
||||
const response = await fetchAPI(`users/me/`);
|
||||
|
||||
if (!response.ok && response.status == 401 && canAttemptSilentLogin()) {
|
||||
const currentLocation = window.location.href;
|
||||
attemptSilentLogin(3600);
|
||||
|
||||
while (window.location.href === currentLocation) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Couldn't fetch user data: ${response.statusText}`,
|
||||
@@ -34,6 +49,13 @@ export function useAuthQuery(
|
||||
queryKey: [KEY_AUTH],
|
||||
queryFn: getMe,
|
||||
staleTime: 1000 * 60 * 15, // 15 minutes
|
||||
retry: (failureCount, error) => {
|
||||
// we assume that a 401 means the user is not logged in
|
||||
if (error.status == 401) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < DEFAULT_QUERY_RETRY;
|
||||
},
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { PropsWithChildren } from 'react';
|
||||
import { Box } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
|
||||
import { HOME_URL } from '../conf';
|
||||
import { useAuth } from '../hooks';
|
||||
import { getAuthUrl, gotoLogin } from '../utils';
|
||||
import { gotoLogin } from '../utils';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
|
||||
@@ -22,28 +23,12 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is authenticated and wanted initially to access a document,
|
||||
* we redirect to the document page.
|
||||
*/
|
||||
if (authenticated) {
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
void replace(authUrl);
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
|
||||
*/
|
||||
if (!authenticated && !pathAllowed) {
|
||||
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
|
||||
void replace('/home');
|
||||
void replace(HOME_URL);
|
||||
} else {
|
||||
gotoLogin();
|
||||
}
|
||||
@@ -57,7 +42,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
/**
|
||||
* If the user is authenticated and the path is the home page, we redirect to the index.
|
||||
*/
|
||||
if (pathname === '/home' && authenticated) {
|
||||
if (pathname === HOME_URL && authenticated) {
|
||||
void replace('/');
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { baseApiUrl } from '@/api';
|
||||
|
||||
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
|
||||
export const HOME_URL = '/home';
|
||||
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
|
||||
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './conf';
|
||||
export * from './hooks';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { gotoLogin } from '@/features/auth';
|
||||
|
||||
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry';
|
||||
|
||||
const isRetryAllowed = () => {
|
||||
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
|
||||
if (!lastRetryDate) {
|
||||
return true;
|
||||
}
|
||||
const now = new Date();
|
||||
return now.getTime() > Number(lastRetryDate);
|
||||
};
|
||||
|
||||
const setNextRetryTime = (retryIntervalInSeconds: number) => {
|
||||
const now = new Date();
|
||||
const nextRetryTime = now.getTime() + retryIntervalInSeconds * 1000;
|
||||
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
|
||||
};
|
||||
|
||||
const initiateSilentLogin = () => {
|
||||
const currentPath = window.location.pathname;
|
||||
gotoLogin(currentPath, true);
|
||||
};
|
||||
|
||||
export const canAttemptSilentLogin = () => {
|
||||
return isRetryAllowed();
|
||||
};
|
||||
|
||||
export const attemptSilentLogin = (retryIntervalInSeconds: number) => {
|
||||
if (!isRetryAllowed()) {
|
||||
return;
|
||||
}
|
||||
setNextRetryTime(retryIntervalInSeconds);
|
||||
initiateSilentLogin();
|
||||
};
|
||||
@@ -1,27 +1,12 @@
|
||||
import { terminateCrispSession } from '@/services/Crisp';
|
||||
|
||||
import { LOGIN_URL, LOGOUT_URL, PATH_AUTH_LOCAL_STORAGE } from './conf';
|
||||
import { LOGIN_URL, LOGOUT_URL } from './conf';
|
||||
|
||||
export const getAuthUrl = () => {
|
||||
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
if (path_auth) {
|
||||
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
return path_auth;
|
||||
}
|
||||
};
|
||||
|
||||
export const setAuthUrl = () => {
|
||||
if (window.location.pathname !== '/') {
|
||||
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
export const gotoLogin = (withRedirect = true) => {
|
||||
if (withRedirect) {
|
||||
setAuthUrl();
|
||||
}
|
||||
|
||||
window.location.replace(LOGIN_URL);
|
||||
export const gotoLogin = (returnTo = '/', isSilent = false) => {
|
||||
const authenticateUrl =
|
||||
LOGIN_URL +
|
||||
`?silent=${encodeURIComponent(isSilent)}&returnTo=${window.location.origin + returnTo}`;
|
||||
window.location.replace(authenticateUrl);
|
||||
};
|
||||
|
||||
export const gotoLogout = () => {
|
||||
|
||||
+6
-3
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
import { useUploadFile } from '../hook';
|
||||
@@ -27,12 +27,13 @@ import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { DividerBlock } from './custom-blocks';
|
||||
import { CalloutBlock, DividerBlock } from './custom-blocks';
|
||||
|
||||
export const blockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
callout: CalloutBlock,
|
||||
divider: DividerBlock,
|
||||
},
|
||||
}),
|
||||
@@ -48,7 +49,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const { setEditor } = useEditorStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const readOnly = !doc.abilities.partial_update;
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
|
||||
|
||||
useSaveDoc(doc.id, provider.document, !readOnly);
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
|
||||
+5
-1
@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DocsBlockSchema } from '../types';
|
||||
|
||||
import { getDividerReactSlashMenuItems } from './custom-blocks';
|
||||
import {
|
||||
getCalloutReactSlashMenuItems,
|
||||
getDividerReactSlashMenuItems,
|
||||
} from './custom-blocks';
|
||||
|
||||
export const BlockNoteSuggestionMenu = () => {
|
||||
const editor = useBlockNoteEditor<DocsBlockSchema>();
|
||||
@@ -25,6 +28,7 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
combineByGroup(
|
||||
getDefaultReactSlashMenuItems(editor),
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
),
|
||||
query,
|
||||
|
||||
+6
-1
@@ -6,9 +6,12 @@ import {
|
||||
useDictionary,
|
||||
} from '@blocknote/react';
|
||||
import React, { JSX, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConfig } from '@/core/config/api';
|
||||
|
||||
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
|
||||
|
||||
import { AIGroupButton } from './AIButton';
|
||||
import { FileDownloadButton } from './FileDownloadButton';
|
||||
import { MarkdownButton } from './MarkdownButton';
|
||||
@@ -18,11 +21,13 @@ export const BlockNoteToolbar = () => {
|
||||
const dict = useDictionary();
|
||||
const [confirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
|
||||
const { t } = useTranslation();
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
const toolbarItems = useMemo(() => {
|
||||
const toolbarItems = getFormattingToolbarItems([
|
||||
...blockTypeSelectItems(dict),
|
||||
getCalloutFormattingToolbarItems(t),
|
||||
]);
|
||||
const fileDownloadButtonIndex = toolbarItems.findIndex(
|
||||
(item) =>
|
||||
@@ -46,7 +51,7 @@ export const BlockNoteToolbar = () => {
|
||||
}
|
||||
|
||||
return toolbarItems as JSX.Element[];
|
||||
}, [dict]);
|
||||
}, [dict, t]);
|
||||
|
||||
const formattingToolbar = useCallback(() => {
|
||||
return (
|
||||
|
||||
@@ -25,7 +25,6 @@ interface DocEditorProps {
|
||||
|
||||
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const isVersion = !!versionId && typeof versionId === 'string';
|
||||
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
interface EmojiPickerProps {
|
||||
categories: string[];
|
||||
custom: {
|
||||
name: string;
|
||||
id: string;
|
||||
emojis: string[];
|
||||
}[];
|
||||
onClickOutside: () => void;
|
||||
onEmojiSelect: ({ native }: { native: string }) => void;
|
||||
}
|
||||
|
||||
export const EmojiPicker = ({
|
||||
categories,
|
||||
custom,
|
||||
onClickOutside,
|
||||
onEmojiSelect,
|
||||
}: EmojiPickerProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
|
||||
<Picker
|
||||
categories={categories}
|
||||
custom={custom}
|
||||
data={data}
|
||||
locale={i18n.resolvedLanguage}
|
||||
navPosition="none"
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
theme="light"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, Icon } from '@/components';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
import { EmojiPicker } from '../EmojiPicker';
|
||||
|
||||
const calloutCustom = [
|
||||
{
|
||||
name: 'Callout',
|
||||
id: 'callout',
|
||||
emojis: [
|
||||
'bulb',
|
||||
'point_right',
|
||||
'point_up',
|
||||
'ok_hand',
|
||||
'key',
|
||||
'construction',
|
||||
'warning',
|
||||
'fire',
|
||||
'pushpin',
|
||||
'scissors',
|
||||
'question',
|
||||
'no_entry',
|
||||
'no_entry_sign',
|
||||
'alarm_clock',
|
||||
'phone',
|
||||
'rotating_light',
|
||||
'recycle',
|
||||
'white_check_mark',
|
||||
'lock',
|
||||
'paperclip',
|
||||
'book',
|
||||
'speaking_head_in_silhouette',
|
||||
'arrow_right',
|
||||
'loudspeaker',
|
||||
'hammer_and_wrench',
|
||||
'gear',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const calloutCategories = [
|
||||
'callout',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'flags',
|
||||
'objects',
|
||||
'symbols',
|
||||
];
|
||||
|
||||
export const CalloutBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'callout',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
backgroundColor: defaultProps.backgroundColor,
|
||||
emoji: { default: '💡' },
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: ({ block, editor, contentRef }) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
};
|
||||
|
||||
const onClickOutside = () => setOpenEmojiPicker(false);
|
||||
|
||||
const onEmojiSelect = ({ native }: { native: string }) => {
|
||||
editor.updateBlock(block, { props: { emoji: native } });
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
// Temporary: sets a yellow background color to a callout block when added by
|
||||
// the user, while keeping the colors menu on the drag handler usable for
|
||||
// this custom block.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!block.content.length &&
|
||||
block.props.backgroundColor === 'default'
|
||||
) {
|
||||
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
|
||||
}
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="1rem"
|
||||
$gap="0.625rem"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
$align="center"
|
||||
$height="28px"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
categories={calloutCategories}
|
||||
custom={calloutCustom}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
)}
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getCalloutReactSlashMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
title: t('Callout'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'callout',
|
||||
});
|
||||
},
|
||||
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
|
||||
group,
|
||||
icon: <Icon iconName="lightbulb" $size="18px" />,
|
||||
subtext: t('Add a callout block'),
|
||||
},
|
||||
];
|
||||
|
||||
export const getCalloutFormattingToolbarItems = (
|
||||
t: TFunction<'translation', undefined>,
|
||||
): BlockTypeSelectItem => ({
|
||||
name: t('Callout'),
|
||||
type: 'callout',
|
||||
icon: () => <Icon iconName="lightbulb" $size="16px" />,
|
||||
isSelected: (block) => block.type === 'callout',
|
||||
});
|
||||
+1
@@ -1 +1,2 @@
|
||||
export * from './CalloutBlock';
|
||||
export * from './DividerBlock';
|
||||
|
||||
@@ -31,7 +31,7 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
|
||||
_updatedDoc: Y.Doc,
|
||||
transaction: Y.Transaction,
|
||||
) => {
|
||||
setIsLocalChange(transaction.local ? true : false);
|
||||
setIsLocalChange(transaction.local);
|
||||
};
|
||||
|
||||
yDoc.on('update', onUpdate);
|
||||
@@ -61,7 +61,7 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
|
||||
const isSaving = saveDoc();
|
||||
|
||||
/**
|
||||
* Firefox does not trigger the request everytime the user leaves the page.
|
||||
* Firefox does not trigger the request every time the user leaves the page.
|
||||
* Plus the request is not intercepted by the service worker.
|
||||
* So we prevent the default behavior to have the popup asking the user
|
||||
* if he wants to leave the page, by adding the popup, we let the time to the
|
||||
|
||||
@@ -61,6 +61,23 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callout, Paragraph and Heading blocks
|
||||
*/
|
||||
.bn-block {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='paragraph'],
|
||||
.bn-block-content[data-content-type='heading'] {
|
||||
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Paragraph, TextRun } from 'docx';
|
||||
|
||||
import { DocsExporterDocx } from '../types';
|
||||
import { docxBlockPropsToStyles } from '../utils';
|
||||
|
||||
export const blockMappingCalloutDocx: DocsExporterDocx['mappings']['blockMapping']['callout'] =
|
||||
(block, exporter) => {
|
||||
return new Paragraph({
|
||||
...docxBlockPropsToStyles(block.props, exporter.options.colors),
|
||||
spacing: { before: 10, after: 10 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: ' ',
|
||||
break: 1,
|
||||
}),
|
||||
new TextRun(' ' + block.props.emoji + ' '),
|
||||
...exporter.transformInlineContent(block.content),
|
||||
new TextRun({
|
||||
text: ' ',
|
||||
break: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
padding: 8,
|
||||
gap: 4,
|
||||
},
|
||||
emoji: {
|
||||
fontSize: 16,
|
||||
},
|
||||
text: {
|
||||
maxWidth: '94%',
|
||||
paddingTop: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export const blockMappingCalloutPDF: DocsExporterPDF['mappings']['blockMapping']['callout'] =
|
||||
(block, exporter) => {
|
||||
return (
|
||||
<View wrap={false} style={styles.wrapper}>
|
||||
<Text debug={false} style={styles.emoji}>
|
||||
{block.props.emoji}
|
||||
</Text>
|
||||
<Text debug={false} style={styles.text}>
|
||||
{' '}
|
||||
{exporter.transformInlineContent(block.content)}{' '}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
|
||||
const blob = await exporter.resolveFile(block.props.url);
|
||||
let pngConverted: string | undefined;
|
||||
let dimensions: { width: number; height: number } | undefined;
|
||||
let previewWidth = block.props.previewWidth || undefined;
|
||||
|
||||
if (!blob.type.includes('image')) {
|
||||
return [];
|
||||
@@ -28,7 +29,9 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
|
||||
|
||||
if (blob.type.includes('svg')) {
|
||||
const svgText = await blob.text();
|
||||
pngConverted = await convertSvgToPng(svgText, block.props.previewWidth);
|
||||
const FALLBACK_SIZE = 536;
|
||||
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
|
||||
pngConverted = await convertSvgToPng(svgText, previewWidth);
|
||||
const img = new Image();
|
||||
img.src = pngConverted;
|
||||
await new Promise((resolve) => {
|
||||
@@ -47,8 +50,7 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
|
||||
|
||||
const { width, height } = dimensions;
|
||||
|
||||
let previewWidth = block.props.previewWidth;
|
||||
if (previewWidth > MAX_WIDTH) {
|
||||
if (previewWidth && previewWidth > MAX_WIDTH) {
|
||||
previewWidth = MAX_WIDTH;
|
||||
}
|
||||
|
||||
@@ -69,8 +71,8 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
|
||||
}
|
||||
: undefined,
|
||||
transformation: {
|
||||
width: previewWidth,
|
||||
height: (previewWidth / width) * height,
|
||||
width: previewWidth || width,
|
||||
height: ((previewWidth || width) / width) * height,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -12,6 +12,7 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
|
||||
async (block, exporter) => {
|
||||
const blob = await exporter.resolveFile(block.props.url);
|
||||
let pngConverted: string | undefined;
|
||||
let width = block.props.previewWidth || undefined;
|
||||
|
||||
if (!blob.type.includes('image')) {
|
||||
return <View wrap={false} />;
|
||||
@@ -19,7 +20,9 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
|
||||
|
||||
if (blob.type.includes('svg')) {
|
||||
const svgText = await blob.text();
|
||||
pngConverted = await convertSvgToPng(svgText, block.props.previewWidth);
|
||||
const FALLBACK_SIZE = 536;
|
||||
width = width || blob.size || FALLBACK_SIZE;
|
||||
pngConverted = await convertSvgToPng(svgText, width);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -27,7 +30,7 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
|
||||
<Image
|
||||
src={pngConverted || blob}
|
||||
style={{
|
||||
width: block.props.previewWidth * PIXELS_PER_POINT,
|
||||
width: width ? width * PIXELS_PER_POINT : undefined,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './calloutDocx';
|
||||
export * from './calloutPDF';
|
||||
export * from './dividerDocx';
|
||||
export * from './dividerPDF';
|
||||
export * from './headingPDF';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* See:
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/004c0bf720fe1415c497ad56449015c5f4dd7ba0/packages/xl-pdf-exporter/src/pdf/util/table/Table.tsx
|
||||
*
|
||||
* We succeded to manage the colspan, but rowspan is not supported yet.
|
||||
* We succeeded to manage the colspan, but rowspan is not supported yet.
|
||||
*/
|
||||
|
||||
import { TD, TR, Table } from '@ag-media/react-pdf-table';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
|
||||
|
||||
import {
|
||||
blockMappingCalloutDocx,
|
||||
blockMappingDividerDocx,
|
||||
blockMappingImageDocx,
|
||||
blockMappingQuoteDocx,
|
||||
@@ -11,6 +12,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
|
||||
...docxDefaultSchemaMappings,
|
||||
blockMapping: {
|
||||
...docxDefaultSchemaMappings.blockMapping,
|
||||
callout: blockMappingCalloutDocx,
|
||||
divider: blockMappingDividerDocx,
|
||||
quote: blockMappingQuoteDocx,
|
||||
image: blockMappingImageDocx,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
|
||||
|
||||
import {
|
||||
blockMappingCalloutPDF,
|
||||
blockMappingDividerPDF,
|
||||
blockMappingHeadingPDF,
|
||||
blockMappingImagePDF,
|
||||
@@ -14,6 +15,7 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
|
||||
...pdfDefaultSchemaMappings,
|
||||
blockMapping: {
|
||||
...pdfDefaultSchemaMappings.blockMapping,
|
||||
callout: blockMappingCalloutPDF,
|
||||
heading: blockMappingHeadingPDF,
|
||||
image: blockMappingImagePDF,
|
||||
paragraph: blockMappingParagraphPDF,
|
||||
|
||||
@@ -19,9 +19,16 @@ export function downloadFile(blob: Blob, filename: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SVG to PNG
|
||||
* @param svgText - The SVG text to convert
|
||||
* @returns The PNG data URL
|
||||
* Converts an SVG string into a PNG image and returns it as a data URL.
|
||||
*
|
||||
* This function creates a canvas, parses the SVG, calculates the appropriate height
|
||||
* to preserve the aspect ratio, and renders the SVG onto the canvas using Canvg.
|
||||
*
|
||||
* @param {string} svgText - The raw SVG markup to convert.
|
||||
* @param {number} width - The desired width of the output PNG (height is auto-calculated to preserve aspect ratio).
|
||||
* @returns {Promise<string>} A Promise that resolves to a PNG image encoded as a base64 data URL.
|
||||
*
|
||||
* @throws Will throw an error if the canvas context cannot be initialized.
|
||||
*/
|
||||
export async function convertSvgToPng(svgText: string, width: number) {
|
||||
// Create a canvas and render the SVG onto it
|
||||
|
||||
+4
-1
@@ -42,6 +42,7 @@ const doc = {
|
||||
|
||||
beforeEach(() => {
|
||||
Analytics.clearAnalytics();
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
||||
});
|
||||
|
||||
describe('DocToolBox "Copy as HTML" option', () => {
|
||||
@@ -51,7 +52,9 @@ describe('DocToolBox "Copy as HTML" option', () => {
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
const optionsButton = screen.getByLabelText('Open the document options');
|
||||
const optionsButton = await screen.findByLabelText(
|
||||
'Open the document options',
|
||||
);
|
||||
await userEvent.click(optionsButton);
|
||||
expect(await screen.findByText('Copy as HTML')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { DocToolBox } from '../components/DocToolBox';
|
||||
|
||||
const doc = {
|
||||
nb_accesses: 1,
|
||||
abilities: {
|
||||
versions_list: true,
|
||||
destroy: true,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('@/features/docs/doc-export/', () => ({
|
||||
ModalExport: () => <span>ModalExport</span>,
|
||||
}));
|
||||
|
||||
it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
||||
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(await screen.findByText('download')).toBeInTheDocument();
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
const doc = {
|
||||
nb_accesses: 1,
|
||||
abilities: {
|
||||
versions_list: true,
|
||||
destroy: true,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('@/features/docs/doc-export/', () => ({
|
||||
ModalExport: () => <span>ModalExport</span>,
|
||||
}));
|
||||
|
||||
it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => {
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
|
||||
|
||||
const { DocToolBox } = await import('../components/DocToolBox');
|
||||
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByText('download')).not.toBeInTheDocument();
|
||||
},
|
||||
{
|
||||
timeout: 1000,
|
||||
},
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user