Compare commits

..

71 Commits

Author SHA1 Message Date
Jacques ROUSSEL
ae1254dc68 wip fix dockerfile 2024-10-16 14:50:53 +02:00
lebaudantoine
5342eb49c7 wip install y-py 2024-10-16 14:36:20 +02:00
Anthony LC
97d00b678f 🚨(docker) fix docker warning about casing
When we build the docker image, we get a warning
about the casing in the Dockerfile. This commit
fixes the casing in the Dockerfile.
2024-10-14 22:20:54 +02:00
Anthony LC
52eb973164 (CI) refecto test-e2e
With the new container available, we can simplify
the workflow by removing the build step
and using the container directly.
2024-10-14 22:20:54 +02:00
Anthony LC
789879a9cc 🧑‍💻(project) improve frontend bootstrap
We were providing a frontend development container
to the developers, but it was not working properly.
Problem of hot reload was present for Windows and
Linux users.
We stop to provide this development container and
we will provide a container connected to the build
of the frontend.
You can still access the frontend after bootstrap
on the "localhost:3000", but if you want to develop
you will have to install the frontend dependencies
localy and run the frontend in development mode.
This will be more efficient and will avoid the
problem of hot reload, and right on folder access.
2024-10-14 22:20:54 +02:00
Anthony LC
52c52d53b7 🔧(docker) add missing frontent env
The env MEDIA_URL was missing in the frontend
Dockerfile. It is not necessary in our
running environment (staging / preprod ...) but it
is necessary if we want to run the frontend with
a different media url.
SW_DEACTIVATED was missing as well, we need to
deactivate the service worker in the frontend when
we test with Playwright.
2024-10-14 22:20:54 +02:00
Anthony LC
54fe6a2319 🐛(frontend) invalidate queries after removing user
When we remove a user from the list of members,
we need to invalidate the user query for the
user to be found again.
We improve the error message when a user is
already a member of the document.
2024-10-14 19:58:41 +02:00
Anthony LC
bc5dcb0ed5 ️(frontend) use Marianne woff2 if compatible
Woff2 is a more modern format for web fonts,
and it is supported by all modern browsers.
We still keep the woff format for
compatibility with older browsers.
2024-10-11 15:26:18 +02:00
Anthony LC
6c3f3f6a77 💄(frontend) components more multi theme friendly
We adapt a bit the tokens of some components to be
more multi theme friendly.
When we will add another theme, it will be
easier to adapt to the new theme.
2024-10-11 15:26:18 +02:00
Anthony LC
6e64bad1e2 🔖(patch) release 1.5.1
Fixed:
- 🐛(db) fix users duplicate
2024-10-10 16:46:27 +02:00
Anthony LC
0d5b2382ab 🐛(db) fix users duplicate
Some OIDC identity providers provide a random
value in the "sub" field instead of an
identifying ID.
It created duplicate users in the database.
This migration fixes the issue by removing the
duplicate users after having updated all
the references to the old users.
2024-10-10 16:23:46 +02:00
Anthony LC
39d0211593 🔖(minor) release 1.5.0
Added:
- (backend) add name fields to the user synchronized with OIDC
- (ci) add security scan
- (frontend) Activate versions feature
- (frontend) one-click document creation
- (frontend) edit title inline
- 📱(frontend) mobile responsive
- 🌐(frontend) Update translation

Changed:
- 💄(frontend) error alert closeable on editor
- ♻️(backend) Change email content
- 🛂(frontend) viewers and editors can access share modal
- ♻️(frontend) remove footer on doc editor

Fixed:
- 🛂(frontend) match email if no existing user
matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint
- 🛂(frontend) redirect to the OIDC when private doc
and unauthentified
- ♻️(backend) getting list of document versions
available for a user
- 🔧(backend) fix configuration to avoid different
ssl warning
- 🐛(frontend) fix editor break line not working
2024-10-09 16:48:12 +02:00
Anthony LC
86085f87a1 ♻️(frontend) remove share button when not logged in
We remove the share button if the user is not
logged in. Most of the elements in the share modal
nececessitate the user to be logged in.
2024-10-09 16:17:03 +02:00
Anthony LC
ebdcb4b2f0 (frontend) add back the footer and cgu pages
We need to add back the footer and cgu pages,
but we will not display the footer on the doc
editor pages.
2024-10-09 16:17:03 +02:00
Anthony LC
3a0dff5b0e 🐛(service-worker) fix circular import problem
When we were installing the service-worker, errors
were thrown because of circular imports.
This commit fixes the problem by being more explicit
about the imports.
2024-10-09 11:56:35 +02:00
Anthony LC
c682bce6f6 📱(frontend) docs mobile friendly
We adapt the docs component to be
mobile friendly.
2024-10-08 17:25:52 +02:00
Anthony LC
8dd7671d1f 🌐(frontend) add language name to LanguagePicker
The language picker were only showing the language
code, now it shows the language name.
2024-10-08 17:25:52 +02:00
Anthony LC
fe391523c8 📱(frontend) header small mobile friendly
We adapt the header to be small mobile friendly.
We added a burger menu to display the dropdown
menu on small mobile.
2024-10-08 17:25:52 +02:00
Anthony LC
399cf893ad 📱(frontend) add hook store useResponsiveStore
useResponsiveStore is a hook store that provides
the current screen size and the current device type.
2024-10-08 17:25:52 +02:00
Anthony LC
f081f7826a 🔥(frontend) remove footer and legal pages
With the new ui, the footer and legal pages
are no longer needed.
This commit removes them.
2024-10-08 17:25:52 +02:00
Anthony LC
638e1aedb7 🏷️(service-worker) retype the doc creation in SW
Recent changes made the doc creation in SW outdated.
This commit retype the doc creation in SW.
We adapt the main type to fit the new doc type.
2024-10-08 16:30:50 +02:00
Anthony LC
dcbef9630e (e2e) reduce e2e flakiness
We identified some tests causing flakiness
in the e2e tests.
2024-10-08 16:30:50 +02:00
Anthony LC
a745cb7498 🌐(frontend) translate last features
Translate:
- doc visibility
- doc versions
- doc inline title editing
2024-10-08 16:30:50 +02:00
Anthony LC
d701195ae5 🧑‍💻(i18n) rebuild translations for crowdin
For some unexpected reasons it can happen that the
translations in Crowdin are lost.
If that happens, we can rebuild the Crowdin
translations file from our translated json file.
"translations-skeleton.json" is the downloaded
source file from Crowdin.
It will generate "translations-rebuild.json",
which can be uploaded directly to Crowdin.
2024-10-08 16:30:50 +02:00
renovate[bot]
ac18d23fbc ⬆️(dependencies) update python dependencies 2024-10-07 17:32:27 +02:00
Samuel Paccoud - DINUM
ff7914f6d3 🛂(backend) match email if no existing user matches the sub
Some OIDC identity providers may provide a random value in the "sub"
field instead of an identifying ID. In this case, it may be a good
idea to fallback to matching the user on its email field.
2024-10-04 22:08:39 +02:00
Anthony LC
647e6c1cf5 ⬆️(frontend) upgrade blocknote to 0.16.0
Version 0.16.0 of Blocknote fixes the breakline issue.
2024-10-04 11:04:41 +02:00
Anthony LC
98b60ebe93 🐛(frontend) fix infinity scroll on invitation list
The infinity scroll had some difficulties to
load the next page of invitations because the ref
could be not init.
This commit fixes the issue.
2024-10-04 11:04:41 +02:00
Anthony LC
0b15ebba71 🛂(frontend) readers and editors can access share modal
Readers and editors of a document can access the share
modal and see the list of members and their roles.
2024-10-04 11:04:41 +02:00
Samuel Paccoud - DINUM
eee20033ae (backend) add full_name and short_name to user model and API
The full_name and short_name field are synchronized with the OIDC
token upon each login.
2024-10-03 23:39:56 +02:00
Anthony LC
e642506675 🚚(frontend) move Markdown button to is own file
To keep the codebase clean and organized,
we moved the Markdown button to its own file.
2024-10-02 15:24:29 +02:00
Anthony LC
883055b5fb (frontend) first heading is the title of the document
When the title of the doc is not set, the first heading
is used as the title of the document.
2024-10-02 15:24:29 +02:00
Anthony LC
968a1383f7 (frontend) initial editor content is now a heading
When we create a new document,
the initial content is now a heading instead of a
paragraph.
This is to make it easier to set the title
of the document.
2024-10-02 15:24:29 +02:00
Anthony LC
6a2030e235 ♻️(frontend) change useHeading to useHeadingStore
We need to get the headings in multiple places.
To not have multiple listeners to compute the same
thing, we will use a store to store the editor
headings.
2024-10-02 15:24:29 +02:00
Anthony LC
4d2a73556a 🔥(frontend) remove useless update title codes
We can now update the title directly in the header,
so we don't need the update title modal anymore.
We remove the buttons to trigger the modal
and the modal itself.
2024-10-02 15:24:29 +02:00
Anthony LC
90027d3a5a (frontend) edit title inline
We can now edit the title of the document inline.
This is a feature that is very useful for users
who want to change the title of the document
without having to go to the document
management page.
2024-10-02 15:24:29 +02:00
Anthony LC
61593bd807 ♻️(frontend) one click create doc
We can now create a doc in one click.
The doc will be created with a default name,
the user will be able to edit the name inline.
2024-10-02 15:24:29 +02:00
Anthony LC
99ebc9fc9c 🚚(frontend) rename useTransRole to useTrans
We rename useTransRole to useTrans to make
it more general and reusable.
It will be used for all reusable doc translation.
2024-10-02 15:24:29 +02:00
Anthony LC
a5e798164c 🚚(frontend) add utils userAgent
In order to stay DRY, we moved the function
to know if a user is from a firefox browser
from useSaveDoc to utils userAgent.
2024-10-02 15:24:29 +02:00
Anthony LC
002b9340e3 🛂(frontend) redirect to the OIDC when private doc
We now redirect to the OIDC when a user is on
a private doc and get a 401 error.
2024-10-02 15:24:29 +02:00
Anthony LC
f00f833ee2 🐛(frontend) fix sticky panel editor
the sticky panel editor is not working properly,
the panel editor should be sticky until the bottom
when the user scrolls the page.
2024-10-02 15:24:29 +02:00
Jacques ROUSSEL
3a6bc8c0f7 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-10-01 09:27:37 +02:00
virgile-dev
76368f1ae9 📝(project) update README.md
Minimal update to the readme file to better reflect the project.
2024-09-30 17:52:14 +02:00
Anthony LC
fab86f7f87 ⬆️(dependencies) bump js dependencies
We bump the js dependencies to their latest version.
2024-09-30 17:39:28 +02:00
Anthony LC
ac74db2fde ♻️(frontend) add versions in the panel editor
We add the features version to the panel editor.
We had to refactor the panel to be able to
have the version with the table of content in
the same panel.
2024-09-30 17:26:23 +02:00
Anthony LC
b2480eea74 ♻️(frontend) minor components refacto
Improve some props in different components.
2024-09-30 17:26:23 +02:00
Anthony LC
20a898c978 👔(frontend) adapt versions api with new types
We updated the backend recently, the types of the
versions list has changed.
This commit adapts the frontend to the new types.
2024-09-30 17:26:23 +02:00
renovate[bot]
589d3abd8d ⬆️(dependencies) update python dependencies 2024-09-30 12:39:51 +02:00
renovate[bot]
1ba588d416 ⬆️(dependencies) update js dependencies 2024-09-30 11:57:15 +02:00
Anthony LC
b1f37495d6 🚑️(backend) fix CVEs in backend image
Use alpine version for production image instead of
debian in order to have less CVEs.
2024-09-30 10:59:52 +02:00
Jacques ROUSSEL
8c9cb43097 🚑️(frontend) fixe CVEs in frontend image
Use alpine version for production image instead of debian in order to
have less CVEs.
2024-09-30 10:59:52 +02:00
Jacques ROUSSEL
aeeed8feb5 (ci) add security scan
Add a security scan for CVE with trivy
2024-09-30 10:59:52 +02:00
Anthony LC
1e89eb1a21 🛂(frontend) redirect to the OIDC when private doc
We now redirect to the OIDC when a user is on
a private doc and is not authentified.
2024-09-27 16:04:31 +02:00
Anthony LC
413e0bebad 🐛(frontend) fix redirection after login
The redirection after login was not working properly.
The user was redirected to the home page
instead of the page he was trying to access.
2024-09-27 16:04:31 +02:00
Samuel Paccoud - DINUM
a2a184bb93 ♻️(api) refactor getting versions to expose pagination
Getting versions was not working properly. Some versions returned
were not accessible by the user requesting the list of available
versions.

We refactor the code to make it simpler and let the frontend handle
pagination (load more style).
2024-09-27 14:59:32 +02:00
Anthony LC
827d8cc8e1 ♻️(backend) change email invitation content
Change the email invitation content. More
document related variables are added.
To benefit of the document inheritance, we moved
the function email_invitation to the document model.
2024-09-26 09:58:11 +02:00
Anthony LC
833c53f5aa 💄(frontend) error alert closeable on editor
When we were uploading a file that was not allowed,
an error alert was shown. This alert was not closeable.
This commit makes the alert closeable.
2024-09-24 16:38:25 +02:00
Jacques ROUSSEL
2775a74bdb (ci) add helmfile linter and fix issue in argocd sync
Add a github job to run helmfile linter on PR
Add argocd annotation to fix job syncing issue
2024-09-24 14:09:26 +02:00
Anthony LC
450790366d (CI) fix flaky test on MinIO initialized
MinIO server need to be initialized before
running the job to configure MinIO.
We add a delay to wait for MinIO server to be ready.
2024-09-24 09:45:09 +02:00
Anthony LC
7b04f664cd (backend) fix flaky test on tmp file
It seems to have a race condition, sometimes the
tmp file is not deleted before the test assertion.
We let the test sleep for 0.5 second before
the assertion.
2024-09-24 09:45:09 +02:00
renovate[bot]
358508ffa3 ⬆️(dependencies) update python dependencies 2024-09-23 11:32:22 +02:00
Anthony LC
9388c8f8f4 🛂(backend) oidc userinfo endpoint json format
The userinfo endpoint can return 2 content types:
- application/json
- application/jwt

Gitlab oidc returns a json object, while
Agent Connect oidc returns a jwt token.
We are adapting the authentication to handle both cases.
2024-09-23 10:57:57 +02:00
renovate[bot]
40d8c949d9 ⬆️(dependencies) update js dependencies 2024-09-23 10:43:10 +02:00
Jacques ROUSSEL
6b0b052d78 🔒️(helm) fix secret sync precedence
When new secret is added to backend secret, it's not sync at the
beginning of argocd synchronisation and jobs are blocked. Theses new
annotations fix this issue.
2024-09-20 18:29:10 +02:00
Anthony LC
ac86a4e7f7 🔖(minor) release 1.4.0
Added:
- (backend) Add link public/authenticated/restricted
access with read/editor roles
- (frontend) add copy link button
- 🛂(frontend) access public docs without being logged

Changed:
- ♻️(backend) Allow null titles on documents
for easier creation
- 🛂(backend) stop to list public doc to everyone
- 🚚(frontend) change visibility in share modal
- ️(frontend) Improve summary

Fixed:
- 🐛(backend) Fix forcing ID when creating a
document via API endpoint
- 🐛 Rebuild frontend dev container from makefile
2024-09-18 12:01:52 +02:00
Anthony LC
bbe5501297 🌐(frontend) translate last features
Translate:
- doc visibility
- doc table of contents
2024-09-18 11:18:29 +02:00
Anthony LC
b37acf3138 💄(frontend) improve ui table of contents
- keep correctly the text on the left side
- improve accuracy highlightment heading when scrolling
- display full heading text when text transform is applied
- fix typo
2024-09-18 11:18:29 +02:00
Anthony LC
5bd78b8068 🚚(frontend) rename feature summary to table of content
We rename the feature summary to table of content
to better reflect the feature purpose.
2024-09-17 15:06:37 +02:00
Anthony LC
ed39c01608 ♻️(frontent) improve summary feature
- Change Summary to Table of content
- No dash before the title
- Change font-size depend the type of heading
- If more than 2 headings the panel is open
by default
- improve sticky
- highligth the title where you are in the page
2024-09-17 15:06:37 +02:00
Anthony LC
748ebc8f26 🔧(helm) change conf helm dev
Some frontend env vars were added on the frontend
side, we need to add them to the dev helm chart.
2024-09-17 15:06:37 +02:00
renovate[bot]
03262878c4 ⬆️(dependencies) update js dependencies 2024-09-16 14:30:29 +02:00
181 changed files with 16999 additions and 16461 deletions

View File

@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -48,9 +49,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
@@ -92,9 +99,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
@@ -137,9 +150,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile

22
.github/workflows/helmfile-linter.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"

View File

@@ -39,29 +39,6 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
test-front:
runs-on: ubuntu-latest
needs: install-front
@@ -98,25 +75,11 @@ jobs:
test-e2e-chromium:
runs-on: ubuntu-latest
needs: build-front
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make data/media
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
@@ -124,42 +87,11 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Start Docker services
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
@@ -176,25 +108,12 @@ jobs:
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: build-front
needs: test-e2e-chromium
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make data/media
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
@@ -202,42 +121,11 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Start Docker services
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium

View File

@@ -9,52 +9,52 @@ on:
- "*"
jobs:
# lint-git:
# runs-on: ubuntu-latest
# if: github.event_name == 'pull_request' # Makes sense only for pull requests
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# with:
# fetch-depth: 0
# - name: show
# run: git log
# - name: Enforce absence of print statements in code
# run: |
# ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
# - name: Check absence of fixup commits
# run: |
# ! git log | grep 'fixup!'
# - name: Install gitlint
# run: pip install --user requests gitlint
# - name: Lint commit messages added to main
# run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
run: |
! git log | grep 'fixup!'
- name: Install gitlint
run: pip install --user requests gitlint
- name: Lint commit messages added to main
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
# check-changelog:
# runs-on: ubuntu-latest
# if: |
# contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
# github.event_name == 'pull_request'
# steps:
# - name: Checkout repository
# uses: actions/checkout@v3
# with:
# fetch-depth: 50
# - name: Check that the CHANGELOG has been modified in the current branch
# run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
# lint-changelog:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# - name: Check CHANGELOG max line length
# run: |
# max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
# if [ $max_line_length -ge 80 ]; then
# echo "ERROR: CHANGELOG has lines longer than 80 characters."
# exit 1
# fi
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
build-mails:
runs-on: ubuntu-latest
@@ -96,112 +96,121 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
# lint-back:
# runs-on: ubuntu-latest
# defaults:
# run:
# working-directory: src/backend
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# - name: Install Python
# uses: actions/setup-python@v3
# with:
# python-version: "3.10"
# - name: Install development dependencies
# run: pip install --user .[dev]
# - name: Check code formatting with ruff
# run: ~/.local/bin/ruff format . --diff
# - name: Lint code with ruff
# run: ~/.local/bin/ruff check .
# - name: Lint code with pylint
# run: ~/.local/bin/pylint .
lint-back:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
# test-back:
# runs-on: ubuntu-latest
# needs: build-mails
test-back:
runs-on: ubuntu-latest
needs: build-mails
# defaults:
# run:
# working-directory: src/backend
defaults:
run:
working-directory: src/backend
# services:
# postgres:
# image: postgres:16
# env:
# POSTGRES_DB: impress
# POSTGRES_USER: dinum
# POSTGRES_PASSWORD: pass
# ports:
# - 5432:5432
# # needed because the postgres container does not provide a healthcheck
# options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
# env:
# DJANGO_CONFIGURATION: Test
# DJANGO_SETTINGS_MODULE: impress.settings
# DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
# OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
# DB_HOST: localhost
# DB_NAME: impress
# DB_USER: dinum
# DB_PASSWORD: pass
# DB_PORT: 5432
# STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
# AWS_S3_ENDPOINT_URL: http://localhost:9000
# AWS_S3_ACCESS_KEY_ID: impress
# AWS_S3_SECRET_ACCESS_KEY: password
env:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: impress
AWS_S3_SECRET_ACCESS_KEY: password
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
steps:
- name: Checkout repository
uses: actions/checkout@v4
# - name: Create writable /data
# run: |
# sudo mkdir -p /data/media && \
# sudo mkdir -p /data/static
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
# - name: Restore the mail templates
# uses: actions/cache@v4
# id: mail-templates
# with:
# path: "src/backend/core/templates/mail"
# key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
# - name: Start Minio
# run: |
# docker pull minio/minio
# docker run -d --name minio \
# -p 9000:9000 \
# -e "MINIO_ACCESS_KEY=impress" \
# -e "MINIO_SECRET_KEY=password" \
# -v /data/media:/data \
# minio/minio server --console-address :9001 /data
- name: Start MinIO
run: |
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=impress" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# - name: Configure MinIO
# run: |
# MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
# docker exec ${MINIO} sh -c \
# "mc alias set impress http://localhost:9000 impress password && \
# mc alias ls && \
# mc mb impress/impress-media-storage && \
# mc version enable impress/impress-media-storage"
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
# - name: Install Python
# uses: actions/setup-python@v3
# with:
# python-version: "3.10"
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
# - name: Install development dependencies
# run: pip install --user .[dev]
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set impress http://localhost:9000 impress password && \
mc alias ls && \
mc mb impress/impress-media-storage && \
mc version enable impress/impress-media-storage"
# - name: Install gettext (required to compile messages)
# run: |
# sudo apt-get update
# sudo apt-get install -y gettext pandoc
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
# - name: Generate a MO file from strings extracted from the project
# run: python manage.py compilemessages
- name: Install development dependencies
run: pip install --user .[dev]
# - name: Run tests
# run: ~/.local/bin/pytest -n 2
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2

View File

@@ -9,6 +9,54 @@ and this project adheres to
## [Unreleased]
## Changed
- ♻️(frontend) More multi theme friendly #325
- ♻️ Bootstrap frontend #257
## Fixed
🐛(frontend) invalidate queries after removing user #336
## [1.5.1] - 2024-10-10
## Fixed
- 🐛(db) fix users duplicate #316
## [1.5.0] - 2024-10-09
## Added
- ✨(backend) add name fields to the user synchronized with OIDC #301
- ✨(ci) add security scan #291
- ♻️(frontend) Add versions #277
- ✨(frontend) one-click document creation #275
- ✨(frontend) edit title inline #275
- 📱(frontend) mobile responsive #304
- 🌐(frontend) Update translation #308
## Changed
- 💄(frontend) error alert closeable on editor #284
- ♻️(backend) Change email content #283
- 🛂(frontend) viewers and editors can access share modal #302
- ♻️(frontend) remove footer on doc editor #313
## Fixed
- 🛂(frontend) match email if no existing user matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint #232
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
- ♻️(backend) getting list of document versions available for a user #258
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
- ✨Add link public/authenticated/restricted access with read/editor roles #234
@@ -17,13 +65,14 @@ and this project adheres to
## Changed
- ♻️ Allow null titles on documents for easier creation #234
- ♻️(backend) Allow null titles on documents for easier creation #234
- 🛂(backend) stop to list public doc to everyone #234
- 🚚(frontend) change visibility in share modal #235
- ⚡️(frontend) Improve summary #244
## Fixed
- 🐛 Fix forcing ID when creating a document via API endpoint #234
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
@@ -149,7 +198,10 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.3.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.5.1...main
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0

View File

@@ -1,18 +1,20 @@
# Django impress
# ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
RUN apk update && \
apk upgrade
# ---- Back-end builder image ----
FROM base as back-builder
FROM base AS back-builder
RUN apk add \
cargo
WORKDIR /builder
@@ -24,7 +26,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 as mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -35,15 +37,13 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base as link-collector
FROM base AS link-collector
ARG IMPRESS_STATIC_ROOT=/data/static
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Install pango & rdfind
RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -62,23 +62,21 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
# ---- Core application image ----
FROM base as core
FROM base AS core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
pandoc \
fonts-noto-color-emoji \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
pandoc \
font-noto-emoji \
font-noto \
shared-mime-info
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -102,15 +100,13 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core as backend-development
FROM core AS backend-development
# Switch back to the root user to install development dependencies
USER root:root
# Install psql
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
RUN apk add postgresql-client
# Uninstall impress and re-install it in editable mode along with development
# dependencies
@@ -130,7 +126,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as backend-production
FROM core AS backend-production
ARG IMPRESS_STATIC_ROOT=/data/static

View File

@@ -81,7 +81,7 @@ bootstrap: \
data/static \
create-env-files \
build \
run-frontend-dev \
run-with-frontend \
migrate \
demo \
back-i18n-compile \
@@ -90,11 +90,28 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
build: cache ?= --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
build-backend: cache ?=
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend
build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider $(cache)
.PHONY: build-yjs-provider
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -110,6 +127,11 @@ run: ## start the wsgi (production) and development server
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -286,10 +308,15 @@ help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# Front
run-frontend-dev: ## Install and run the frontend dev
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-frontend-dev
# Front
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-install
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
@@ -314,7 +341,7 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
VERSION_TYPE ?= minor
bump-packages-version: VERSION_TYPE ?= minor
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)

View File

@@ -1,9 +1,15 @@
# Impress
Impress prints your markdown to pdf from predefined templates with user and role based access rights.
Impress is a web application for real-time collaborative text editing with user and role based access rights.
Features include :
- User authentication through OIDC
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
- Document export to pdf and docx from predefined templates
- Granular document permissions
- Public link sharing
- Offline mode
Impress is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
## Getting started
@@ -31,14 +37,6 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap FLUSH_ARGS='--no-input'
```
Then you can access to the project in development mode by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
---
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
@@ -46,12 +44,41 @@ dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
Note that if you need to run them afterwards, you can use the eponym Make rule:
You can access to the project by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
$ make run-frontend-dev
$ make run-with-frontend
```
---
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```bash
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```bash
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```bash
$ make run
```
---
### Adding content
You can create a basic demo site by running:

View File

@@ -119,13 +119,20 @@ services:
depends_on:
- keycloak
nginx-front:
image: nginx:1.25
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
Y_PROVIDER_URL: "ws://localhost:4444"
MEDIA_URL: "http://localhost:8083"
SW_DEACTIVATED: "true"
image: impress:frontend-development
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
dockerize:
image: jwilder/dockerize
@@ -161,21 +168,6 @@ services:
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress:/home/frontend/apps/impress
- /home/frontend/node_modules/
depends_on:
- y-provider
- celery-dev
kc_postgresql:
image: postgres:14.3
ports:

Submodule secrets updated: 2643697e5f...38594182e8

View File

@@ -447,10 +447,10 @@ max-bool-expr=5
max-branches=12
# Maximum number of locals for function / method body
max-locals=15
max-locals=20
# Maximum number of parents for a class (see R0901).
max-parents=7
max-parents=10
# Maximum number of public methods for a class (see R0904).
max-public-methods=20

View File

@@ -29,7 +29,19 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
{
@@ -58,6 +70,7 @@ class UserAdmin(auth_admin.UserAdmin):
list_display = (
"id",
"sub",
"full_name",
"admin_email",
"email",
"is_active",
@@ -68,9 +81,24 @@ class UserAdmin(auth_admin.UserAdmin):
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
ordering = (
"is_active",
"-is_superuser",
"-is_staff",
"-is_device",
"-updated_at",
"full_name",
)
readonly_fields = (
"id",
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.Template)

View File

@@ -16,8 +16,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "sub", "email"]
read_only_fields = ["id", "sub", "email"]
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer):
@@ -343,10 +343,10 @@ class InvitationSerializer(serializers.ModelSerializer):
return attrs
class DocumentVersionSerializer(serializers.Serializer):
"""Serialize Versions."""
class VersionFilterSerializer(serializers.Serializer):
"""Validate version filters applied to the list endpoint."""
etag = serializers.CharField()
is_latest = serializers.BooleanField()
last_modified = serializers.DateTimeField()
version_id = serializers.CharField()
version_id = serializers.CharField(required=False, allow_blank=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)

View File

@@ -10,6 +10,7 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
Min,
OuterRef,
Q,
Subquery,
@@ -31,7 +32,6 @@ from rest_framework import (
)
from core import models
from core.utils import email_invitation
from . import permissions, serializers, utils
@@ -374,28 +374,36 @@ class DocumentViewSet(
Return the document's versions but only those created after the user got access
to the document
"""
if not request.user.is_authenticated:
user = request.user
if not user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
# Validate query parameters using dedicated serializer
serializer = serializers.VersionFilterSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
document = self.get_object()
user = request.user
from_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = document.accesses.filter(
Q(user=user) | Q(team__in=user.teams)
).aggregate(min_date=Min("created_at"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return exceptions.PermissionDenied(
"Only users with specific access can see version history"
)
versions_data = document.get_versions_slice(
from_version_id=serializer.validated_data.get("version_id"),
min_datetime=min_datetime,
page_size=serializer.validated_data.get("page_size"),
)
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
"versions"
]
paginator = pagination.PageNumberPagination()
paginated_versions = paginator.paginate_queryset(versions_data, request)
serialized_versions = serializers.DocumentVersionSerializer(
paginated_versions, many=True
)
return paginator.get_paginated_response(serialized_versions.data)
return drf_response.Response(versions_data)
@decorators.action(
detail=True,
@@ -415,13 +423,13 @@ class DocumentViewSet(
# Don't let users access versions that were created before they were given access
# to the document
user = request.user
from_datetime = min(
min_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
)
)
if response["LastModified"] < from_datetime:
if response["LastModified"] < min_datetime:
raise Http404
if request.method == "DELETE":
@@ -567,9 +575,10 @@ class DocumentAccessViewSet(
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
email_invitation(language, access.user.email, access.document.id)
access.document.email_invitation(
language, access.user.email, access.role, self.request.user.email
)
class TemplateViewSet(
@@ -769,4 +778,6 @@ class InvitationViewset(
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
email_invitation(language, invitation.email, invitation.document.id)
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user.email
)

View File

@@ -1,5 +1,6 @@
"""Authentication Backends for the Impress core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -45,56 +46,75 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
userinfo = self.verify_token(user_response.text)
try:
userinfo = user_response.json()
except ValueError:
try:
userinfo = self.verify_token(user_response.text)
except Exception as e:
raise SuspiciousOperation(
_("Invalid response format or token verification failed")
) from e
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
"""Return a User based on userinfo. Create a new user if no match is found."""
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
email = user_info.get("email")
if sub is None:
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
claims = {
"email": email,
"full_name": full_name,
"short_name": short_name,
}
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
user = None
user = self.get_existing_user(sub, email)
if user:
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
return user
def create_user(self, claims):
"""Return a newly created User instance."""
sub = claims.get("sub")
if sub is None:
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
user = User.objects.create(
sub=sub,
email=claims.get("email"),
password="!", # noqa: S106
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
return user
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub, is_active=True)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email, is_active=True)
except User.DoesNotExist:
pass
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)

View File

@@ -22,6 +22,8 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2024-09-29 03:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]

View File

@@ -0,0 +1,128 @@
# Generated by Django 5.1.1 on 2024-10-10 11:45
from django.db import migrations
procedure = """
DO $$
DECLARE
user_email TEXT;
BEGIN
-- Step 1: Create a temporary table (without the unique constraint)
-- impress_document_access
DROP TABLE IF EXISTS impress_document_access_tmp;
CREATE TEMP TABLE impress_document_access_tmp AS
SELECT * FROM impress_document_access;
-- impress_link_trace
DROP TABLE IF EXISTS impress_link_trace_tmp;
CREATE TEMP TABLE impress_link_trace_tmp AS
SELECT * FROM impress_link_trace;
-- Step 2: Loop through each email that appears more than once
FOR user_email IN
SELECT email
FROM impress_user
GROUP BY email
HAVING COUNT(email) > 1
LOOP
-- Step 3: Update user_id in the temporary table based on email
-- For impress_document_access
UPDATE impress_document_access_tmp
SET user_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE user_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
-- For impress_link_trace
UPDATE impress_link_trace_tmp
SET user_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE user_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
-- update impress_invitation
UPDATE impress_invitation
SET issuer_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE issuer_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
DELETE FROM impress_user
WHERE id IN (
SELECT id
FROM impress_user
WHERE email = user_email
)
AND id != (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
);
RAISE NOTICE 'Processed updates for email: %', user_email;
END LOOP;
-- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id)
-- For impress_document_access
DELETE FROM impress_document_access_tmp a
USING impress_document_access_tmp b
WHERE a.ctid < b.ctid -- Keep one row
AND a.document_id = b.document_id
AND a.user_id = b.user_id;
-- Step 5: Replace the original table with the cleaned-up temporary table
TRUNCATE TABLE impress_document_access;
-- Insert cleaned-up data back into the original table
INSERT INTO impress_document_access
SELECT * FROM impress_document_access_tmp;
-- For impress_link_trace
DELETE FROM impress_link_trace_tmp a
USING impress_link_trace_tmp b
WHERE a.ctid < b.ctid -- Keep one row
AND a.document_id = b.document_id
AND a.user_id = b.user_id;
-- Step 5: Replace the original table with the cleaned-up temporary table
TRUNCATE TABLE impress_link_trace;
-- Insert cleaned-up data back into the original table
INSERT INTO impress_link_trace
SELECT * FROM impress_link_trace_tmp;
RAISE NOTICE 'Update and deduplication process completed.';
END $$;
"""
class Migration(migrations.Migration):
dependencies = [
('core', '0006_add_user_full_name_and_short_name'),
]
operations = [
migrations.RunSQL(procedure),
]

View File

@@ -3,6 +3,7 @@ Declare and configure the models for the impress core application
"""
import hashlib
import smtplib
import tempfile
import textwrap
import uuid
@@ -13,16 +14,20 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import models
from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -140,6 +145,10 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
blank=True,
null=True,
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
@@ -411,73 +420,62 @@ class Document(BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def get_versions_slice(
self, from_version_id="", from_datetime=None, page_size=None
):
def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None):
"""Get document versions from object storage with pagination and starting conditions"""
# /!\ Trick here /!\
# The "KeyMarker" and "VersionIdMarker" fields must either be both set or both not set.
# The error we get otherwise is not helpful at all.
token = {}
markers = {}
if from_version_id:
token.update(
markers.update(
{"KeyMarker": self.file_key, "VersionIdMarker": from_version_id}
)
if from_datetime:
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
**token,
)
# Find the first version after the given datetime
version = None
for version in response.get("Versions", []):
if version["LastModified"] >= from_datetime:
token = {
"KeyMarker": self.file_key,
"VersionIdMarker": version["VersionId"],
}
break
else:
if version is None or version["LastModified"] < from_datetime:
if response["NextVersionIdMarker"]:
return self.get_versions_slice(
from_version_id=response["NextVersionIdMarker"],
page_size=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
from_datetime=from_datetime,
)
return {
"next_version_id_marker": "",
"is_truncated": False,
"versions": [],
}
real_page_size = (
min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
if page_size
else settings.DOCUMENT_VERSIONS_PAGE_SIZE
)
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
if page_size
else settings.DOCUMENT_VERSIONS_PAGE_SIZE,
**token,
# compensate the latest version that we exclude below and get one more to
# know if there are more pages
MaxKeys=real_page_size + 2,
**markers,
)
min_last_modified = min_datetime or self.created_at
versions = [
{
key_snake: version[key_camel]
for key_snake, key_camel in [
("etag", "ETag"),
("is_latest", "IsLatest"),
("last_modified", "LastModified"),
("version_id", "VersionId"),
]
}
for version in response.get("Versions", [])
if version["LastModified"] >= min_last_modified
and version["IsLatest"] is False
]
results = versions[:real_page_size]
count = len(results)
if count == len(versions):
is_truncated = False
next_version_id_marker = ""
else:
is_truncated = True
next_version_id_marker = versions[count - 1]["version_id"]
return {
"next_version_id_marker": response["NextVersionIdMarker"],
"is_truncated": response["IsTruncated"],
"versions": [
{
key_snake: version[key_camel]
for key_camel, key_snake in [
("ETag", "etag"),
("IsLatest", "is_latest"),
("LastModified", "last_modified"),
("VersionId", "version_id"),
]
}
for version in response.get("Versions", [])
],
"next_version_id_marker": next_version_id_marker,
"is_truncated": is_truncated,
"versions": results,
"count": count,
}
def delete_version(self, version_id):
@@ -522,6 +520,39 @@ class Document(BaseModel):
"versions_retrieve": can_get_versions,
}
def email_invitation(self, language, email, role, username_sender):
"""Send email invitation."""
domain = Site.objects.get_current().domain
try:
with override(language):
title = _("%(username)s shared a document with you: %(document)s") % {
"username": username_sender,
"document": self.title,
}
template_vars = {
"title": title,
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"username": username_sender,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
class LinkTrace(BaseModel):
"""

View File

@@ -1,8 +1,12 @@
"""Unit tests for the Authentication Backends."""
import re
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
@@ -34,6 +38,130 @@ def test_authentication_getter_existing_user_no_email(
assert user == db_user
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
@@ -52,6 +180,8 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
assert user.sub == "123"
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -77,11 +207,13 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert models.User.objects.count() == 1
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
@@ -102,3 +234,74 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch):
"""Test get_userinfo method with a token response."""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response():
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="Invalid response format or token verification failed",
):
oidc_backend.get_userinfo("fake_access_token", None, None)

View File

@@ -171,7 +171,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert "docs/" + str(document.id) + "/" in email_content
@@ -225,5 +225,5 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert "docs/" + str(document.id) + "/" in email_content

View File

@@ -118,7 +118,10 @@ def test_api_document_invitations__create__privileged_members(
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert (
f"{user.email} shared a document with you: {document.title}"
in email_content
)
else:
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Invitation.objects.exists() is False
@@ -158,7 +161,10 @@ def test_api_document_invitations__create__email_from_content_language():
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation à rejoindre Docs !" in email_content
assert (
f"{user.email} a partagé un document avec vous: {document.title}"
in email_content
)
def test_api_document_invitations__create__email_from_content_language_not_supported():
@@ -196,7 +202,7 @@ def test_api_document_invitations__create__email_from_content_language_not_suppo
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert f"{user.email} shared a document with you: {document.title}" in email_content
def test_api_document_invitations__create__issuer_and_document_override():

View File

@@ -60,7 +60,7 @@ def test_api_document_versions_list_authenticated_unrelated(reach):
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
def test_api_document_versions_list_authenticated_related_success(via, mock_user_teams):
"""
Authenticated users should be able to list document versions for a document
to which they are directly related, whatever their role in the document.
@@ -95,12 +95,12 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 0
assert content["count"] == 0
# Add a new version to the document
document.content = "new content"
document.save()
for i in range(3):
document.content = f"new content {i:d}"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
@@ -108,8 +108,100 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
# The current version is not listed
assert content["count"] == 2
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related_pagination(
via, mock_user_teams
):
"""
The list of versions should be paginated and exclude versions that were created prior to the
user gaining access to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
for i in range(3):
document.content = f"before {i:d}"
document.save()
if via == USER:
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
)
for i in range(4):
document.content = f"after {i:d}"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
content = response.json()
assert content["is_truncated"] is False
# The current version is not listed
assert content["count"] == 3
assert content["next_version_id_marker"] == ""
all_version_ids = [version["version_id"] for version in content["versions"]]
# - set page size
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2",
)
content = response.json()
assert content["count"] == 2
assert content["is_truncated"] is True
marker = content["next_version_id_marker"]
assert marker == all_version_ids[1]
assert [
version["version_id"] for version in content["versions"]
] == all_version_ids[:2]
# - get page 2
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}",
)
content = response.json()
assert content["count"] == 1
assert content["is_truncated"] is False
assert content["next_version_id_marker"] == ""
assert content["versions"][0]["version_id"] == all_version_ids[2]
def test_api_document_versions_list_exceeds_max_page_size():
"""Page size should not exceed the limit set on the serializer"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[user])
document.content = "version 2"
document.save()
response = client.get(f"/api/v1.0/documents/{document.id!s}/versions/?page_size=51")
assert response.status_code == 400
assert response.json() == {
"page_size": ["Ensure this value is less than or equal to 50."]
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@@ -119,6 +211,9 @@ def test_api_document_versions_retrieve_anonymous(reach):
restricted or authenticated link reach.
"""
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
@@ -142,6 +237,9 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -157,7 +255,7 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
associated document versions.
"""
user = factories.UserFactory()
@@ -165,6 +263,10 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
client.force_login(user)
document = factories.DocumentFactory()
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
@@ -173,6 +275,8 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
time.sleep(1) # minio stores datetimes with the precision of a second
# Versions created before the document was shared should not be seen by the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
@@ -180,11 +284,26 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
assert response.status_code == 404
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
# Create a new version should not make it available to the user because
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.save()
assert len(document.get_versions_slice()["versions"]) == 2
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Adding one more version should make the previous version available to the user
document.content = "new content 2"
document.save()
assert len(document.get_versions_slice()["versions"]) == 3
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -192,7 +311,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
)
assert response.status_code == 200
assert response.json()["content"] == "new content"
assert response.json()["content"] == "new content 1"
def test_api_document_versions_create_anonymous():
@@ -260,10 +379,15 @@ def test_api_document_versions_create_authenticated_related(via, mock_user_teams
def test_api_document_versions_update_anonymous():
"""Anonymous users should not be allowed to update a document version."""
access = factories.UserDocumentAccessFactory()
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
document = access.document
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = APIClient().put(
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
{"foo": "bar"},
format="json",
)
@@ -281,7 +405,12 @@ def test_api_document_versions_update_authenticated_unrelated():
client.force_login(user)
access = factories.UserDocumentAccessFactory()
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
document = access.document
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
@@ -303,7 +432,6 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
client.force_login(user)
document = factories.DocumentFactory()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
@@ -311,6 +439,14 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id!s}/",
{"foo": "bar"},
@@ -345,6 +481,9 @@ def test_api_document_versions_delete_authenticated(reach):
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
@@ -381,13 +520,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
version_id = versions[1]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 403
assert len(versions) == 1
version_id = versions[0]["version_id"]
response = client.delete(
@@ -396,7 +529,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
assert response.status_code == 403
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
assert len(versions) == 1
@pytest.mark.parametrize("via", VIA)
@@ -421,19 +554,25 @@ def test_api_document_versions_delete_administrator_or_owner(via, mock_user_team
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.content = "new content 1"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
assert len(versions) == 1
version_id = versions[1]["version_id"]
version_id = versions[0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
# 404 because the version was created before the user was given access to the document
assert response.status_code == 404
document.content = "new content 2"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
version_id = versions[0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",

View File

@@ -120,6 +120,8 @@ def test_api_users_retrieve_me_authenticated():
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}

View File

@@ -2,9 +2,15 @@
Unit tests for the Document model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import mail
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
@@ -255,7 +261,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
}
def test_models_documents_get_versions_slice(settings):
def test_models_documents_get_versions_slice_pagination(settings):
"""
The "get_versions_slice" method should allow navigating all versions of
the document with pagination.
@@ -268,7 +274,7 @@ def test_models_documents_get_versions_slice(settings):
document.content = f"bar{i:d}"
document.save()
# Add a version not related to the first document
# Add a document version not related to the first document
factories.DocumentFactory()
# - Get default max versions
@@ -286,7 +292,7 @@ def test_models_documents_get_versions_slice(settings):
from_version_id=response["next_version_id_marker"]
)
assert response["is_truncated"] is False
assert len(response["versions"]) == 3
assert len(response["versions"]) == 2
assert response["next_version_id_marker"] == ""
# - Get custom max versions
@@ -296,6 +302,30 @@ def test_models_documents_get_versions_slice(settings):
assert response["next_version_id_marker"] != ""
def test_models_documents_get_versions_slice_min_datetime():
"""
The "get_versions_slice" method should filter out versions anterior to
the from_datetime passed in argument and the current version.
"""
document = factories.DocumentFactory()
from_dt = []
for i in range(6):
from_dt.append(timezone.now())
document.content = f"bar{i:d}"
document.save()
response = document.get_versions_slice(min_datetime=from_dt[2])
assert len(response["versions"]) == 3
for version in response["versions"]:
assert version["last_modified"] > from_dt[2]
response = document.get_versions_slice(min_datetime=from_dt[4])
assert len(response["versions"]) == 1
assert response["versions"][0]["last_modified"] > from_dt[4]
def test_models_documents_version_duplicate():
"""A new version should be created in object storage only if the content has changed."""
document = factories.DocumentFactory()
@@ -322,3 +352,94 @@ def test_models_documents_version_duplicate():
Bucket=default_storage.bucket_name, Prefix=file_key
)
assert len(response["Versions"]) == 2
def test_models_documents__email_invitation__success():
"""
The email invitation is sent successfully.
"""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, "sender@example.com"
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"sender@example.com invited you as an editor on the following document : {document.title}"
in email_content
)
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in french.
"""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
document.email_invitation(
"fr-fr", "guest2@example.com", models.RoleChoices.OWNER, "sender2@example.com"
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest2@example.com"]
email_content = " ".join(email.body.split())
assert (
f"sender2@example.com vous a invité en tant que propriétaire "
f"sur le document suivant : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@mock.patch(
"core.models.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
document.email_invitation(
"en", "guest3@example.com", models.RoleChoices.ADMIN, "sender3@example.com"
)
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == "guest3@example.com"
assert isinstance(exception, smtplib.SMTPException)

View File

@@ -3,6 +3,7 @@ Unit tests for the Template model
"""
import os
import time
from unittest import mock
from django.contrib.auth.models import AnonymousUser
@@ -203,7 +204,7 @@ def test_models_templates__generate_word():
"pypandoc.convert_text",
side_effect=RuntimeError("Conversion failed"),
)
def test_models_templates__generate_word__raise_error(_mock_send_mail):
def test_models_templates__generate_word__raise_error(_mock_pypandoc):
"""
Generate word document and assert no tmp files are left in /tmp folder
even when the conversion fails.
@@ -214,4 +215,5 @@ def test_models_templates__generate_word__raise_error(_mock_send_mail):
template.generate_word("<p>Test body</p>", {})
except RuntimeError as e:
assert str(e) == "Conversion failed"
time.sleep(0.5)
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0

View File

@@ -1,87 +0,0 @@
"""
Unit tests for the Invitation model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.core import mail
import pytest
from core.utils import email_invitation
pytestmark = pytest.mark.django_db
def test_utils__email_invitation_success():
"""
The email invitation is sent successfully.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert "docs/123-456-789/" in email_content
def test_utils__email_invitation_success_fr():
"""
The email invitation is sent successfully in french.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("fr-fr", "guest@example.com", "123-456-789")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation à rejoindre Docs !" in email_content
assert "docs/123-456-789/" in email_content
@mock.patch(
"core.utils.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_utils__email_invitation_failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == "guest@example.com"
assert isinstance(exception, smtplib.SMTPException)

View File

@@ -1,40 +0,0 @@
"""
Utilities for the core app.
"""
import smtplib
from logging import getLogger
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
logger = getLogger(__name__)
def email_invitation(language, email, document_id):
"""Send email invitation."""
try:
with override(language):
title = _("Invitation to join Docs!")
template_vars = {
"title": title,
"site": Site.objects.get_current(),
"document_id": document_id,
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)

View File

@@ -2,6 +2,7 @@
"""create_demo management command"""
import logging
import math
import random
import time
from collections import defaultdict
@@ -111,7 +112,11 @@ def create_demo(stdout):
queue = BulkQueue(stdout)
with Timeit(stdout, "Creating users"):
name_size = int(math.sqrt(defaults.NB_OBJECTS["users"]))
first_names = [fake.first_name() for _ in range(name_size)]
last_names = [fake.last_name() for _ in range(name_size)]
for i in range(defaults.NB_OBJECTS["users"]):
first_name = random.choice(first_names)
queue.push(
models.User(
admin_email=f"user{i:d}@example.com",
@@ -120,6 +125,8 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
short_name=first_name,
full_name=f"{first_name:s} {random.choice(last_names):s}",
language=random.choice(settings.LANGUAGES)[0],
)
)

View File

@@ -384,10 +384,27 @@ class Base(Configuration):
OIDC_STORE_ID_TOKEN = values.BooleanValue(
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=True,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -546,6 +563,14 @@ class Production(Base):
# In other cases, you should comment the following line to avoid security issues.
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,547 +17,330 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
#: core/admin.py:32
msgid "Personal info"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
#: core/admin.py:34
msgid "Permissions"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
#: core/admin.py:46
msgid "Important dates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
msgid "Owner"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
msgid "id"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
msgid "created on"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
msgid "updated on"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
msgid "sub"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
msgid "identity email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
msgid "admin email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
msgid "language"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
msgid "device"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
msgid "staff status"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
msgid "active"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
msgid "user"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
msgid "users"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
msgid "title"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
msgid "Template"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
msgid "Templates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
msgid "Template/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
msgid "Template/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
msgid "This user is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
msgid "This team is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
msgid "English"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr ""
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr ""
#: core/models.py:71
msgid "Administrator"
msgstr ""
#: core/models.py:72
msgid "Owner"
msgstr ""
#: core/models.py:80
msgid "Restricted"
msgstr ""
#: core/models.py:84
msgid "Authenticated"
msgstr ""
#: core/models.py:86
msgid "Public"
msgstr ""
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
msgid "identity email address"
msgstr ""
#: core/models.py:153
msgid "admin email address"
msgstr ""
#: core/models.py:160
msgid "language"
msgstr ""
#: core/models.py:161
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
msgid "device"
msgstr ""
#: core/models.py:172
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
msgid "staff status"
msgstr ""
#: core/models.py:177
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
msgid "active"
msgstr ""
#: core/models.py:183
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
msgid "user"
msgstr ""
#: core/models.py:196
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "Hello %(name)s"
msgid "%(username)s shared a document with you: %(document)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
msgid "public"
msgstr ""
#: core/models.py:651
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
msgid "Template"
msgstr ""
#: core/models.py:658
msgid "Templates"
msgstr ""
#: core/models.py:797
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
msgid "email address"
msgstr ""
#: core/models.py:850
msgid "Document invitation"
msgstr ""
#: core/models.py:851
msgid "Document invitations"
msgstr ""
#: core/models.py:868
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr ""
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr ""
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid "With Docs, you will be able to:"
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/text/invitation.txt:16
msgid "Work offline."
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr ""
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/text/hello.txt:8
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgid "%(username)s shared a document with you"
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr ""
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr ""
#: impress/settings.py:176
msgid "English"
msgstr ""
#: impress/settings.py:177
msgid "French"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:21\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,547 +17,330 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
#: core/admin.py:32
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
#: core/admin.py:34
msgid "Permissions"
msgstr "Permissions"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
#: core/admin.py:46
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
msgid "Owner"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
msgid "id"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
msgid "created on"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
msgid "updated on"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
msgid "sub"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
msgid "identity email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
msgid "admin email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
msgid "language"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
msgid "device"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
msgid "staff status"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
msgid "active"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
msgid "user"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
msgid "users"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
msgid "title"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
msgid "Template"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
msgid "Templates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
msgid "Template/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
msgid "Template/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
msgid "This user is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
msgid "This team is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
msgid "English"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
msgstr "Invitation à rejoindre Docs !"
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Lecteur"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:71
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:72
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:80
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:86
msgid "Public"
msgstr "Public"
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
msgid "identity email address"
msgstr ""
#: core/models.py:153
msgid "admin email address"
msgstr ""
#: core/models.py:160
msgid "language"
msgstr ""
#: core/models.py:161
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
msgid "device"
msgstr ""
#: core/models.py:172
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
msgid "staff status"
msgstr ""
#: core/models.py:177
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
msgid "active"
msgstr ""
#: core/models.py:183
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
msgid "user"
msgstr ""
#: core/models.py:196
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "Hello %(name)s"
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s a partagé un document avec vous: %(document)s"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
msgid "public"
msgstr ""
#: core/models.py:651
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
msgid "Template"
msgstr ""
#: core/models.py:658
msgid "Templates"
msgstr ""
#: core/models.py:797
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
msgid "email address"
msgstr ""
#: core/models.py:850
msgid "Document invitation"
msgstr ""
#: core/models.py:851
msgid "Document invitations"
msgstr ""
#: core/models.py:868
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr "Invitation à rejoindre un document !"
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s a partagé un document avec vous ! "
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr "Bienvenue sur <strong>Docs !</strong>"
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s vous a invité en tant que %(role)s sur le document suivant : "
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr "Nous sommes heureux de vous accueillir dans notre communauté sur Docs, votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement, et en toute sécurité."
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr "Notre application est conçue pour vous aider à organiser, collaborer et gérer vos permissions."
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid "With Docs, you will be able to:"
msgstr "Avec Docs, vous serez capable de :"
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr "Créez des documents."
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/text/invitation.txt:16
msgid "Work offline."
msgstr "Travailler hors ligne."
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Proposé par La Suite Numérique"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr "Invitez des membres de votre communauté sur votre document en quelques clics."
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr "Visitez Docs"
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr "Nous sommes persuadés que Docs vous aidera à améliorer votre efficacité et votre productivité tout en renforçant les liens entre vos membres."
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr "N'hésitez pas à explorer toutes les fonctionnalités de l'application et à nous faire part de vos commentaires et suggestions. Vos commentaires nous sont précieux et nous permettront d'améliorer continuellement notre service."
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr "Encore une fois, bienvenue à bord ! Nous sommes impatients de vous accompagner dans votre aventure collaborative."
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr "Sincèrement,"
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr "L'équipe La Suite Numérique"
#: core/templates/mail/text/hello.txt:8
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgid "%(username)s shared a document with you"
msgstr "%(username)s a partagé un document avec vous"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s vous a invité en tant que %(role)s sur le document suivant :"
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr "Docs, votre nouvel outil essentiel pour organiser, partager et collaborer sur votre document en équipe."
#: impress/settings.py:176
msgid "English"
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr "Bienvenue sur Docs !"
#: impress/settings.py:177
msgid "French"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.3.0"
version = "1.5.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,38 +25,39 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.10",
"boto3==1.35.34",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.8",
"redis==5.1.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1",
"django==5.1.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.1",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"pypandoc==1.13",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.13.0",
"sentry-sdk==2.15.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"y-py==0.6.2"
]
[project.urls]
@@ -70,18 +71,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.27.0",
"ipython==8.28.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.2.7",
"pylint==3.3.1",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
"pytest==8.3.2",
"pytest==8.3.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.3",
"types-requests==2.32.0.20240712",
"ruff==0.6.9",
"types-requests==2.32.0.20240914",
]
[tool.setuptools]

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine as frontend-deps-y-provider
FROM node:20-alpine AS frontend-deps-y-provider
WORKDIR /home/frontend/
@@ -15,7 +15,7 @@ COPY ./src/frontend/ .
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-provider ----
FROM frontend-deps-y-provider as y-provider
FROM frontend-deps-y-provider AS y-provider
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
@@ -28,7 +28,7 @@ ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]
FROM node:20-alpine as frontend-deps
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -43,11 +43,11 @@ COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps as impress
FROM frontend-deps AS impress
WORKDIR /home/frontend/apps/impress
FROM frontend-deps as impress-dev
FROM frontend-deps AS impress-dev
WORKDIR /home/frontend/apps/impress
@@ -57,7 +57,7 @@ CMD [ "yarn", "dev"]
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
# to avoid rebuilding the app at every changes.
FROM impress as impress-builder
FROM impress AS impress-builder
WORKDIR /home/frontend/apps/impress
@@ -70,10 +70,16 @@ ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER

View File

@@ -1,10 +1,6 @@
import { Page, expect } from '@playwright/test';
export const keyCloakSignIn = async (page: Page, browserName: string) => {
const title = await page.locator('h1').first().textContent({
timeout: 5000,
});
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
@@ -12,7 +8,7 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
} else if (title?.includes('Sign in to your account')) {
} else {
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
@@ -33,32 +29,21 @@ export const createDoc = async (
length: number,
isPublic: boolean = false,
) => {
const buttonCreate = page.getByRole('button', {
name: 'Create the document',
});
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
// Fill input
await page
.getByRole('textbox', {
name: 'Document name',
.getByRole('button', {
name: 'Create a new document',
})
.fill(randomDocs[i]);
.click();
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
if (isPublic) {
await page.getByRole('button', { name: 'Share' }).click();
@@ -128,13 +113,14 @@ export const goToGridDoc = async (
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = datagrid.getByRole('row');
const rows = datagridTable.getByRole('row');
const row = title
? rows.filter({
hasText: title,
@@ -147,7 +133,7 @@ export const goToGridDoc = async (
expect(docTitle).toBeDefined();
await docTitleCell.click();
await row.getByRole('link').first().click();
return docTitle as string;
};
@@ -155,7 +141,13 @@ export const goToGridDoc = async (
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
await route.fulfill({
json: {
id: 'mocked-document-id',
@@ -183,3 +175,82 @@ export const mockedDocument = async (page: Page, json: object) => {
}
});
};
export const mockedInvitations = async (page: Page, json?: object) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('invitations') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: '120ec765-43af-4602-83eb-7f4e1224548a',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
is_expired: false,
...json,
},
],
},
});
} else {
await route.continue();
}
});
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('accesses') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
},
team: '',
role: 'reader',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
set_role_to: ['administrator', 'editor'],
},
...json,
},
],
},
});
} else {
await route.continue();
}
});
};

View File

@@ -7,59 +7,25 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Create', () => {
test('checks all the create doc elements are visible', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByRole('dialog').first();
await expect(
card.locator('h2').getByText('Create a new document'),
).toBeVisible();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Create the document',
}),
).toBeVisible();
await expect(card.getByLabel('Close the modal')).toBeVisible();
});
test('checks the cancel button interaction', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByRole('dialog').first();
await card.getByLabel('Close the modal').click();
await expect(buttonCreateHomepage).toBeVisible();
});
test('it creates a doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
expect(await page.locator('title').textContent()).toMatch(
/My new doc - Docs/,
await page.waitForFunction(
() => document.title.match(/My new doc - Docs/),
{ timeout: 5000 },
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible({
timeout: 5000,
});
});
});

View File

@@ -40,19 +40,18 @@ test.describe('Doc Editor', () => {
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('[test markdown](http://test-markdown.html)');
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('[test markdown](http://test-markdown.html)');
await expect(page.getByText('[test markdown]')).toBeVisible();
await expect(editor.getByText('[test markdown]')).toBeVisible();
await page.getByText('[test markdown]').dblclick();
await editor.getByText('[test markdown]').dblclick();
await page.locator('button[data-test="convertMarkdown"]').click();
await expect(page.getByText('[test markdown]')).toBeHidden();
await expect(editor.getByText('[test markdown]')).toBeHidden();
await expect(
page.getByRole('link', {
editor.getByRole('link', {
name: 'test markdown',
}),
).toHaveAttribute('href', 'http://test-markdown.html');
@@ -64,38 +63,40 @@ test.describe('Doc Editor', () => {
// Check the first doc
const firstDoc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 1');
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc 1');
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
// Check the second doc
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(page.getByText('Hello World Doc 1')).toBeHidden();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 2');
await expect(page.getByText('Hello World Doc 2')).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
await editor.fill('Hello World Doc 2');
await expect(editor.getByText('Hello World Doc 2')).toBeVisible();
// Check the first doc again
await goToGridDoc(page, {
title: firstDoc,
});
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await expect(page.getByText('Hello World Doc 2')).toBeHidden();
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({ page }) => {
// Check the first doc
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('Hello World Doc persisted 1');
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc persisted 1');
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
@@ -107,7 +108,7 @@ test.describe('Doc Editor', () => {
title: doc,
});
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
});
test('it saves the doc when we quit pages', async ({ page, browserName }) => {
@@ -117,11 +118,11 @@ test.describe('Doc Editor', () => {
// Check the first doc
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('Hello World Doc persisted 2');
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc persisted 2');
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
await page.goto('/');
@@ -129,7 +130,7 @@ test.describe('Doc Editor', () => {
title: doc,
});
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page }) => {

View File

@@ -85,7 +85,7 @@ test.describe('Doc Export', () => {
page,
browserName,
}) => {
test.slow();
test.setTimeout(60000);
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
let body = '';

View File

@@ -117,7 +117,9 @@ test.describe('Documents Grid', () => {
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
@@ -134,7 +136,9 @@ test.describe('Documents Grid', () => {
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
@@ -155,7 +159,9 @@ test.describe('Documents Grid', () => {
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
@@ -212,26 +218,6 @@ test.describe('Documents Grid', () => {
).toHaveText(/.*/);
});
test('it updates document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page.getByText('Update document').click();
await page.getByLabel('Document name').fill(`${docName} updated`);
await page.getByText('Validate the modification').click();
await expect(datagrid.getByText(`${docName} updated`)).toBeVisible();
});
test('it deletes the document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
@@ -241,11 +227,9 @@ test.describe('Documents Grid', () => {
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page
await docRow
.getByRole('button', {
name: 'Delete document',
name: 'Delete the document',
})
.click();
@@ -266,3 +250,87 @@ test.describe('Documents Grid', () => {
await expect(datagrid.getByText(docName!)).toBeHidden();
});
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: 'b7fd9d9b-0642-4b4f-8617-ce50f69519ed',
title: 'My mocked document',
accesses: [
{
id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f',
user: {
id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
email: 'test@test.test',
full_name: 'John Doe',
short_name: 'John',
},
team: '',
role: 'owner',
abilities: {
destroy: false,
update: false,
partial_update: false,
retrieve: true,
set_role_to: [],
},
},
],
abilities: {
attachment_upload: true,
destroy: true,
link_configuration: true,
manage_accesses: true,
partial_update: true,
retrieve: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
link_role: 'reader',
link_reach: 'public',
created_at: '2024-10-07T13:02:41.085298Z',
updated_at: '2024-10-07T13:30:21.829690Z',
},
],
},
});
} else {
await route.continue();
}
});
await page.goto('/');
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const tableDatagrid = datagrid.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = tableDatagrid.getByRole('row');
const row = rows.filter({
hasText: 'My mocked document',
});
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
});
});

View File

@@ -1,6 +1,12 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
import {
createDoc,
goToGridDoc,
mockedAccesses,
mockedDocument,
mockedInvitations,
} from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -65,49 +71,66 @@ test.describe('Doc Header', () => {
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the doc', async ({ page, browserName }) => {
test('it updates the title doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByRole('heading', { name: randomDoc }).fill(' ');
await page.getByText('Created at').click();
await expect(
page.getByRole('heading', { name: 'Untitled document' }),
).toBeVisible();
});
test('it updates the title doc from editor heading', async ({ page }) => {
await page
.getByRole('button', {
name: 'Update document',
name: 'Create a new document',
})
.click();
const docHeader = page.getByLabel(
'It is the card information about the document.',
);
await expect(
page.locator('h2').getByText(`Update document "${randomDoc}"`),
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
await page.getByText('Document name').fill(`${randomDoc}-updated`);
const editor = page.locator('.ProseMirror');
await page
.getByRole('button', {
name: 'Validate the modification',
})
.click();
await editor.locator('h1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await expect(
page.getByText('The document has been updated.'),
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
).toBeVisible();
const docTitle = await goToGridDoc(page, {
title: `${randomDoc}-updated`,
});
await expect(
page.getByText('Document title updated successfully'),
).toBeVisible();
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await docHeader
.getByRole('heading', { name: 'Hello World', level: 2 })
.fill('Top World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await editor.locator('h1').fill('Super World');
await expect(
page.getByRole('textbox', { name: 'Document name' }),
).toHaveValue(`${randomDoc}-updated`);
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
).toBeVisible();
await editor.locator('h1').fill('');
await docHeader
.getByRole('heading', { name: 'Top World', level: 2 })
.fill(' ');
await page.getByText('Created at').click();
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
});
test('it deletes the doc', async ({ page, browserName }) => {
@@ -165,21 +188,55 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page);
await mockedAccesses(page);
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(shareModal.getByText('Search by email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(
invitationCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -197,20 +254,61 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page, {
abilities: {
destroy: false,
update: false,
partial_update: false,
retrieve: true,
},
});
await mockedAccesses(page);
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
@@ -229,21 +327,93 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page, {
abilities: {
destroy: false,
update: false,
partial_update: false,
retrieve: true,
},
});
await mockedAccesses(page);
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
});
test.describe('Documents Header mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the close button on Share modal', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: true, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await expect(page.getByLabel('Share modal')).toBeHidden();
});
});

View File

@@ -66,6 +66,61 @@ test.describe('Document list members', () => {
).toBeVisible();
});
test('it checks a big list of invitations', async ({ page }) => {
await page.route(
/.*\/documents\/.*\/invitations\/\?page=.*/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: 'http://anything/?page=2',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
team: '',
role: 'editor',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List invitation card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
});
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
@@ -106,15 +161,17 @@ test.describe('Document list members', () => {
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
const shareModal = page.getByLabel('Share modal');
// Admin still have the right to share
await expect(page.locator('h3').getByText('Share')).toBeVisible();
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(page.locator('h3').getByText('Share')).toBeHidden();
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
});
test('it checks the delete members', async ({ page, browserName }) => {
@@ -159,6 +216,8 @@ test.describe('Document list members', () => {
page.getByText('The member has been removed from the document').first(),
).toBeVisible();
await expect(page.getByText('Share')).toBeHidden();
await expect(
page.getByRole('heading', { name: 'Share', level: 3 }),
).toBeHidden();
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
import { keyCloakSignIn, mockedDocument } from './common';
test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => {
@@ -43,9 +43,12 @@ test.describe('Doc Routing: Not loggued', () => {
page,
browserName,
}) => {
await mockedDocument(page, { link_reach: 'public' });
await page.goto('/docs/mocked-document-id/');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByRole('button', { name: 'Login' }).click();
await keyCloakSignIn(page, browserName);
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
});
test('The homepage redirects to login.', async ({ page }) => {

View File

@@ -1,64 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Summary', () => {
test('it checks the doc summary', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-summary', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Summary',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 6; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await panel.getByText('Another World').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
});
});

View File

@@ -0,0 +1,139 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
test.setTimeout(60000);
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Table of contents',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await editor.getByText('Hello').dblclick();
await page.getByRole('button', { name: 'Strike' }).click();
await page.locator('.bn-block-outer').first().click();
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
const hello = panel.getByText('Hello World');
const superW = panel.getByText('Super World');
const another = panel.getByText('Another World');
await expect(hello).toBeVisible();
await expect(hello).toHaveCSS('font-size', /17/);
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toBeVisible();
await expect(superW).toHaveCSS('font-size', /14/);
await expect(superW).toHaveAttribute('aria-selected', 'false');
await expect(another).toBeVisible();
await expect(another).toHaveCSS('font-size', /12/);
await expect(another).toHaveAttribute('aria-selected', 'false');
await hello.click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await another.click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'false');
await expect(superW).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(superW).toHaveAttribute('aria-selected', 'true');
});
test('it checks that table contents panel is opened automaticaly if more that 2 headings', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await expect(page.getByLabel('Open the panel')).toBeHidden();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByLabel('Close the panel')).toBeVisible();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await page.getByLabel('Close the panel').click();
await expect(panel).toHaveAttribute('aria-hidden', 'true');
});
});

View File

@@ -0,0 +1,207 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Version', () => {
test('it displays the doc versions', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(1);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeVisible();
await page
.locator('.ProseMirror .bn-block')
.getByText('Hello World')
.fill('It will create a version');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(2);
await panel.locator('li').nth(1).click();
await expect(
page.getByText('Read only, you cannot edit document versions.'),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await expect(page.getByText('It will create a version')).toBeHidden();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
});
test('it does not display the doc versions if not allowed', async ({
page,
}) => {
await mockedDocument(page, {
abilities: {
versions_list: false,
partial_update: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
).toBeHidden();
await page.getByRole('button', { name: 'Table of content' }).click();
await expect(
page.getByLabel('Document panel').getByText('Versions'),
).toBeHidden();
});
test('it restores the doc version', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
const editor = page.locator('.ProseMirror');
await expect(editor.getByText('Hello')).toBeVisible();
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(page.getByText('World')).toBeHidden();
await panel.getByLabel('Open the version options').click();
await page.getByText('Restore the version').click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden();
});
test('it restores the doc version from button title', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('Hello')).toBeVisible();
await editor.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(editor.getByText('World')).toBeHidden();
await page
.getByRole('button', {
name: 'Restore this version',
})
.click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
});
});

View File

@@ -23,7 +23,9 @@ test.describe('Doc Visibility', () => {
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagrid.getByText(docTitle)).toBeVisible();
@@ -92,5 +94,33 @@ test.describe('Doc Visibility: Not loggued', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
});
test('A private doc redirect to the OIDC when not authentified.', async ({
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'My private doc', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
});

View File

@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -45,6 +47,12 @@ test.describe('Footer', () => {
).toBeVisible();
});
test('checks footer is not visible on doc editor', async ({ page }) => {
await expect(page.locator('footer')).toBeVisible();
await goToGridDoc(page);
await expect(page.locator('footer')).toBeHidden();
});
const legalPages = [
{ name: 'Legal Notice', url: '/legal-notice/' },
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },

View File

@@ -10,8 +10,6 @@ test.describe('Header', () => {
test('checks all the elements are visible', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(header.getByAltText('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
'color',
@@ -28,7 +26,7 @@ test.describe('Header', () => {
}),
).toBeVisible();
await expect(header.getByAltText('Language Icon')).toBeVisible();
await expect(header.getByText('English')).toBeVisible();
await expect(
header.getByRole('button', {
@@ -67,6 +65,42 @@ test.describe('Header', () => {
});
});
test.describe('Header mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the header when mobile', async ({ page }) => {
const header = page.locator('header').first();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeHidden();
await expect(page.getByText('English')).toBeHidden();
await header.getByLabel('Open the header menu').click();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(page.getByText('English')).toBeVisible();
});
});
test.describe('Header: Log out', () => {
test.use({ storageState: { cookies: [], origins: [] } });

View File

@@ -13,9 +13,11 @@ test.describe('Language', () => {
).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'FR' }).click();
await expect(header.getByRole('combobox').getByText('FR')).toBeVisible();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(
page.getByRole('button', {

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.3.0",
"version": "1.5.1",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.47.0",
"@playwright/test": "1.47.2",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
@@ -20,7 +20,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"jsdom": "25.0.0",
"jsdom": "25.0.1",
"pdf-parse": "1.1.1"
}
}

View File

@@ -1,4 +1,5 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=

View File

@@ -189,6 +189,9 @@ const config = {
},
},
},
'la-gauffre': {
activated: false,
},
},
},
dsfr: {
@@ -321,6 +324,7 @@ const config = {
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
},
},
datagrid: {
@@ -335,6 +339,7 @@ const config = {
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
'border-color': 'var(--c--theme--colors--primary-400)',
},
},
'forms-checkbox': {
@@ -384,6 +389,9 @@ const config = {
'forms-textarea': {
'border-radius': '0',
},
'la-gauffre': {
activated: true,
},
},
},
},

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.3.0",
"version": "1.5.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,22 +19,19 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.5",
"@hocuspocus/provider": "2.13.6",
"@openfun/cunningham-react": "2.9.4",
"@socialgouv/e2esdk-client": "1.0.0-beta.28",
"@socialgouv/e2esdk-devtools": "1.0.0-beta.38",
"@socialgouv/e2esdk-react": "1.0.0-beta.28",
"@tanstack/react-query": "5.55.4",
"@tanstack/react-query": "5.56.2",
"i18next": "23.15.1",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.9",
"next": "14.2.13",
"react": "*",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-i18next": "15.0.1",
"react-select": "5.8.0",
"react-i18next": "15.0.2",
"react-select": "5.8.1",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
@@ -42,16 +39,16 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.55.4",
"@tanstack/react-query-devtools": "5.58.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.7",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.9",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.5",
"@types/react": "18.3.10",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -65,7 +62,7 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.94.0",
"webpack": "5.95.0",
"workbox-webpack-plugin": "7.1.0"
}
}

View File

@@ -5,6 +5,14 @@ import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
};
},
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });

View File

@@ -10,14 +10,16 @@ import { APIError } from './APIError';
import { APIList } from './types';
export type UseQueryOptionsAPI<Q> = UseQueryOptions<Q, APIError, Q>;
export type DefinedInitialDataInfiniteOptionsAPI<Q> =
DefinedInitialDataInfiniteOptions<
Q,
APIError,
InfiniteData<Q>,
QueryKey,
number
>;
export type DefinedInitialDataInfiniteOptionsAPI<
Q,
TPageParam = number,
> = DefinedInitialDataInfiniteOptions<
Q,
APIError,
InfiniteData<Q>,
QueryKey,
TPageParam
>;
/**
* @param param Used for infinite scroll pagination

View File

@@ -1,77 +1,101 @@
@font-face {
font-family: Marianne;
src: url('Marianne-Thin.woff') format('truetype');
src:
url('Marianne-Thin.woff2') format('woff2'),
url('Marianne-Thin.woff') format('woff');
font-weight: 100;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Thin_Italic.woff') format('truetype');
src:
url('Marianne-Thin_Italic.woff2') format('woff2'),
url('Marianne-Thin_Italic.woff') format('woff');
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Light.woff') format('truetype');
src:
url('Marianne-Light.woff2') format('woff2'),
url('Marianne-Light.woff') format('woff');
font-weight: 300;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Light_Italic.woff') format('truetype');
src:
url('Marianne-Light_Italic.woff2') format('woff2'),
url('Marianne-Light_Italic.woff') format('woff');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Regular.woff') format('truetype');
src:
url('Marianne-Regular.woff2') format('woff2'),
url('Marianne-Regular.woff') format('woff');
font-weight: 400;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Regular_Italic.woff') format('truetype');
src:
url('Marianne-Regular_Italic.woff2') format('woff2'),
url('Marianne-Regular_Italic.woff') format('woff');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Medium.woff') format('truetype');
src:
url('Marianne-Medium.woff2') format('woff2'),
url('Marianne-Medium.woff') format('woff');
font-weight: 500;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Medium_Italic.woff') format('truetype');
src:
url('Marianne-Medium_Italic.woff2') format('woff2'),
url('Marianne-Medium_Italic.woff') format('woff');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Bold.woff') format('truetype');
src:
url('Marianne-Bold.woff2') format('woff2'),
url('Marianne-Bold.woff') format('woff');
font-weight: 700;
}
@font-face {
font-family: Marianne;
src: url('Marianne-Bold_Italic.woff') format('truetype');
src:
url('Marianne-Bold_Italic.woff2') format('woff2'),
url('Marianne-Bold_Italic.woff') format('woff');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: Marianne;
src: url('Marianne-ExtraBold.woff') format('truetype');
src:
url('Marianne-ExtraBold.woff2') format('woff2'),
url('Marianne-ExtraBold.woff') format('woff');
font-weight: 800;
}
@font-face {
font-family: Marianne;
src: url('Marianne-ExtraBold_Italic.woff') format('truetype');
src:
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
url('Marianne-ExtraBold_Italic.woff') format('woff');
font-weight: 800;
font-style: italic;
}

View File

@@ -21,7 +21,7 @@ export interface BoxProps {
$effect?: 'show' | 'hide';
$flex?: boolean;
$gap?: CSSProperties['gap'];
$hasTransition?: boolean;
$hasTransition?: boolean | 'slow';
$height?: CSSProperties['height'];
$justify?: CSSProperties['justifyContent'];
$overflow?: CSSProperties['overflow'];
@@ -33,6 +33,7 @@ export interface BoxProps {
$padding?: MarginPadding;
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$shrink?: CSSProperties['flexShrink'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
@@ -53,7 +54,11 @@ export const Box = styled('div')<BoxProps>`
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
$hasTransition && `transition: all 0.3s ease-in-out;`}
$hasTransition && $hasTransition === 'slow'
? `transition: all 0.5s ease-in-out;`
: $hasTransition
? `transition: all 0.3s ease-in-out;`
: ''}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $margin }) => $margin && stylesMargin($margin)}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight};`}
@@ -64,6 +69,7 @@ export const Box = styled('div')<BoxProps>`
${({ $padding }) => $padding && stylesPadding($padding)}
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}

View File

@@ -24,6 +24,8 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
ref={ref}
as="button"
$background="none"
$margin="none"
$padding="none"
$css={`
cursor: pointer;
border: none;

View File

@@ -13,8 +13,12 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
$isMaterialIcon
$size="36px"
$theme="primary"
$variation="600"
$background={colorsTokens()['primary-bg']}
$css={`border: 1px solid ${colorsTokens()['primary-200']}`}
$css={`
border: 1px solid ${colorsTokens()['primary-200']};
user-select: none;
`}
$radius="12px"
$padding="4px"
$margin="auto"
@@ -38,6 +42,7 @@ export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
user-select: none;
`}
>
more_vert

View File

@@ -1,101 +0,0 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
interface PanelProps {
title: string;
setIsPanelOpen: (isOpen: boolean) => void;
}
export const Panel = ({
children,
title,
setIsPanelOpen,
}: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setIsOpen(true);
}, []);
const closedOverridingStyles = !isOpen && {
$width: '0',
$maxWidth: '0',
$minWidth: '0',
};
const transition = 'all 0.5s ease-in-out';
return (
<Card
$width="100%"
$maxWidth="20rem"
$position="sticky"
$maxHeight="96vh"
$height="100%"
$css={`
top: 2vh;
transition: ${transition};
${
!isOpen &&
`
box-shadow: none;
border: none;
`
}
`}
aria-label={t('Document panel')}
{...closedOverridingStyles}
>
<Box
$overflow="hidden"
$css={`
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
>
<Box
$padding={{ all: 'small' }}
$direction="row"
$align="center"
$justify="center"
$css={`border-top: 2px solid ${colorsTokens()['primary-600']};`}
>
<IconBG
iconName="menu_open"
aria-label={isOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={1}
$css={`
cursor: pointer;
left: 0rem;
top: 0.1rem;
transition: ${transition};
transform: rotate(180deg);
opacity: ${isOpen ? '1' : '0'};
user-select: none;
`}
$position="absolute"
onClick={() => {
setIsOpen(false);
setTimeout(() => {
setIsPanelOpen(false);
}, 400);
}}
$radius="2px"
/>
<Text $weight="bold" $size="l" $theme="primary">
{title}
</Text>
</Box>
{children}
</Box>
</Card>
);
};

View File

@@ -1,25 +1,34 @@
import { Alert, VariantType } from '@openfun/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, Text, TextType } from '@/components';
const AlertStyled = styled(Alert)`
& .c__button--tertiary:hover {
background-color: var(--c--theme--colors--greyscale-200);
}
`;
interface TextErrorsProps extends TextType {
causes?: string[];
defaultMessage?: string;
icon?: ReactNode;
canClose?: boolean;
}
export const TextErrors = ({
causes,
defaultMessage,
icon,
canClose = false,
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
return (
<Alert canClose={false} type={VariantType.ERROR} icon={icon}>
<AlertStyled canClose={canClose} type={VariantType.ERROR} icon={icon}>
<Box $direction="column" $gap="0.2rem">
{causes &&
causes.map((cause, i) => (
@@ -39,6 +48,6 @@ export const TextErrors = ({
</Text>
)}
</Box>
</Alert>
</AlertStyled>
);
};

View File

@@ -1,10 +1,10 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { E2ESDKClientProvider } from '@socialgouv/e2esdk-react';
import { e2esdkClient } from './auth/useAuthStore';
import { useEffect } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { Auth } from './auth/';
@@ -19,6 +19,7 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 3,
retry: 1,
},
},
});
@@ -26,12 +27,19 @@ const queryClient = new QueryClient({
export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const initializeResizeListener = useResponsiveStore(
(state) => state.initializeResizeListener,
);
useEffect(() => {
const cleanupResizeListener = initializeResizeListener();
return cleanupResizeListener;
}, [initializeResizeListener]);
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<E2ESDKClientProvider client={e2esdkClient}>
<Auth>{children}</Auth>
</E2ESDKClientProvider>
<Auth>{children}</Auth>
</CunninghamProvider>
</QueryClientProvider>
);

View File

@@ -17,8 +17,9 @@ import { useAuthStore } from './useAuthStore';
const regexpUrlsAuth = [/\/docs\/$/g, /^\/$/g];
export const Auth = ({ children }: PropsWithChildren) => {
const { initAuth, initiated, authenticated, login } = useAuthStore();
const { asPath } = useRouter();
const { initAuth, initiated, authenticated, login, getAuthUrl } =
useAuthStore();
const { asPath, replace } = useRouter();
const [pathAllowed, setPathAllowed] = useState<boolean>(
!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)),
@@ -41,6 +42,18 @@ export const Auth = ({ children }: PropsWithChildren) => {
login();
}, [authenticated, pathAllowed, login, initiated]);
// Redirect to the path before login
useEffect(() => {
if (!authenticated) {
return;
}
const authUrl = getAuthUrl();
if (authUrl) {
void replace(authUrl);
}
}, [authenticated, getAuthUrl, replace]);
if ((!initiated && pathAllowed) || (!authenticated && !pathAllowed)) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/core/auth';
export const AccountDropdown = () => {
export const ButtonLogin = () => {
const { t } = useTranslation();
const { logout, authenticated, login } = useAuthStore();

View File

@@ -2,12 +2,10 @@
* Represents user retrieved from the API.
* @interface User
* @property {string} id - The id of the user.
* @property {string} sub - The `sub` field of OIDC
* @property {string} email - The email of the user.
* @property {string} name - The name of the user.
*/
export interface User {
id: string;
sub: string;
email: string;
}

View File

@@ -1,4 +1,4 @@
export * from './AccountDropdown';
export * from './api/types';
export * from './Auth';
export * from './ButtonLogin';
export * from './useAuthStore';

View File

@@ -5,96 +5,58 @@ import { baseApiUrl } from '@/core/conf';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
import { Client, PublicUserIdentity } from '@socialgouv/e2esdk-client';
import { identity } from 'lodash';
export const e2esdkClient = new Client({
// Point it to where your server is listening
serverURL: 'https://app-a5a1b445-32e0-4cf4-a478-821a48f86ccf.cleverapps.io',
// Pass the signature public key you configured for the server
serverSignaturePublicKey: 'ayfva9SUh0mfgmifUtxcdLp4HriHJiqefEKnvYgY4qM',
});
interface AuthStore {
initiated: boolean;
authenticated: boolean;
readyForEncryption: boolean;
initAuth: () => void;
logout: () => void;
login: () => void;
endToEndData?: PublicUserIdentity;
setAuthUrl: (url: string) => void;
getAuthUrl: () => string | undefined;
userData?: User;
}
const initialState = {
initiated: false,
authenticated: false,
readyForEncryption: false,
userData: undefined,
};
export const useAuthStore = create<AuthStore>((set) => ({
export const useAuthStore = create<AuthStore>((set, get) => ({
initiated: initialState.initiated,
authenticated: initialState.authenticated,
userData: initialState.userData,
readyForEncryption: initialState.readyForEncryption,
initAuth: () => {
getMe()
.then(
(data: User) => {
// If a path is stored in the local storage, we redirect to it
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
window.location.replace(path_auth);
return;
}
set({ authenticated: true, userData: data });
return e2esdkClient
.signup(data.sub)
.then(() => data)
.catch(() => data);
},
() => {},
)
.then(
(data) => {
set({ readyForEncryption: true });
if (data) {
return e2esdkClient.login(data.sub);
}
},
(e) => {
throw e;
//if (data) {
// return e2esdkClient.login(data.sub);
//}
//fail
},
)
.then((publicIdentity: PublicUserIdentity | null | undefined) => {
if (!publicIdentity) throw Error('exploding');
console.log('publicIdentity', publicIdentity);
set({ endToEndData: publicIdentity });
.then((data: User) => {
set({ authenticated: true, userData: data });
})
.catch(() => {})
.finally(() => {
console.log('finally');
set({ initiated: true });
});
},
login: () => {
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
}
get().setAuthUrl(window.location.pathname);
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
setAuthUrl() {
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
}
},
// If a path is stored in the local storage, we return it then remove it
getAuthUrl() {
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
return path_auth;
}
},
}));

View File

@@ -150,6 +150,12 @@ input:-webkit-autofill:focus {
border-color: var(--c--components--forms-select--border-color-disabled-hover);
}
.c__select--disabled .c__select__wrapper label,
.c__select--disabled .c__select__wrapper input,
.c__select--disabled .c__select__wrapper {
cursor: not-allowed;
}
.c__select__menu__item {
transition: all var(--c--theme--transitions--duration)
var(--c--theme--transitions--ease-out);
@@ -249,6 +255,7 @@ input:-webkit-autofill:focus {
gap: 3px;
border-radius: 4px;
background: var(--c--components--datagrid--pagination--background-color);
border-color: var(--c--components--datagrid--pagination--border-color);
}
.c__pagination__list .c__button--tertiary-text.c__button--active {
@@ -313,6 +320,14 @@ input:-webkit-autofill:focus {
font-size: var(--c--components--forms-checkbox--text--size);
}
.c__checkbox.c__checkbox--disabled .c__field__text {
color: var(--c--theme--colors--greyscale-600);
}
.c__switch.c__checkbox--disabled .c__switch__rail {
cursor: not-allowed;
}
/**
* Button
*/
@@ -438,6 +453,7 @@ input:-webkit-autofill:focus {
.c__button--tertiary-text {
border: none;
color: var(--c--components--button--tertiary-text--color);
}
.c__button--tertiary-text:hover,
@@ -493,6 +509,23 @@ input:-webkit-autofill:focus {
overflow-y: auto;
}
@media screen and (width <= 420px) {
.c__modal__scroller {
padding: 0.7rem;
}
.c__modal__title h2 {
font-size: 1rem;
}
}
@media (width <= 576px) {
.c__modal__footer--sided {
gap: 0.5rem;
flex-direction: column-reverse;
}
}
/**
* Toast
*/

View File

@@ -283,6 +283,7 @@
);
--c--components--button--disabled--color: white;
--c--components--button--disabled--background--color: #b3cef0;
--c--components--la-gauffre--activated: false;
}
.cunningham-theme--dark {
@@ -451,6 +452,9 @@
--c--components--button--tertiary-text--color-hover: var(
--c--theme--colors--primary-text
);
--c--components--button--tertiary-text--color: var(
--c--theme--colors--primary-600
);
--c--components--datagrid--header--color: var(
--c--theme--colors--primary-text
);
@@ -461,6 +465,9 @@
--c--components--datagrid--pagination--background-color-active: var(
--c--theme--colors--primary-300
);
--c--components--datagrid--pagination--border-color: var(
--c--theme--colors--primary-400
);
--c--components--forms-checkbox--border-radius: 0;
--c--components--forms-checkbox--color: var(--c--theme--colors--primary-text);
--c--components--forms-checkbox--text--color: var(
@@ -504,6 +511,7 @@
--c--theme--colors--primary-text
);
--c--components--forms-textarea--border-radius: 0;
--c--components--la-gauffre--activated: true;
}
.clr-secondary-text {

View File

@@ -276,6 +276,7 @@ export const tokens = {
},
disabled: { color: 'white', background: { color: '#b3cef0' } },
},
'la-gauffre': { activated: false },
},
},
dark: {
@@ -450,6 +451,7 @@ export const tokens = {
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
},
},
datagrid: {
@@ -464,6 +466,7 @@ export const tokens = {
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
'border-color': 'var(--c--theme--colors--primary-400)',
},
},
'forms-checkbox': {
@@ -503,6 +506,7 @@ export const tokens = {
'accent-color': 'var(--c--theme--colors--primary-text)',
},
'forms-textarea': { 'border-radius': '0' },
'la-gauffre': { activated: true },
},
},
},

View File

@@ -1,4 +1,4 @@
import { Block, BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -13,16 +13,19 @@ import { Version } from '@/features/docs/doc-versioning/';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore } from '../stores';
import { useDocStore, useHeadingStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
import { useE2ESDKClient } from '@socialgouv/e2esdk-react';
const cssEditor = `
const cssEditor = (readonly: boolean) => `
&, & > .bn-container, & .ProseMirror {
height:100%
};
& .bn-editor {
padding-right: 30px;
${readonly && `padding-left: 30px;`}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
@@ -31,6 +34,35 @@ const cssEditor = `
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 560px) {
& .bn-editor {
padding-left: 40px;
padding-right: 10px;
${readonly && `padding-left: 10px;`}
};
.bn-side-menu[data-block-type=heading][data-level="1"] {
height: 46px;
}
.bn-side-menu[data-block-type=heading][data-level="2"] {
height: 40px;
}
.bn-side-menu[data-block-type=heading][data-level="3"] {
height: 40px;
}
& .bn-editor h1 {
font-size: 1.6rem;
}
& .bn-editor h2 {
font-size: 1.35rem;
}
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type="paragraph"]
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
}
`;
interface BlockNoteEditorProps {
@@ -71,15 +103,16 @@ export const BlockNoteContent = ({
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
const canSave = doc.abilities.partial_update && !isVersion;
const e2eClient = useE2ESDKClient();
const readOnly = !doc.abilities.partial_update || isVersion;
useSaveDoc(doc.id, provider.document, !readOnly);
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const { setHeadings, resetHeadings } = useHeadingStore();
const uploadFile = useCallback(
async (file: File) => {
@@ -101,56 +134,51 @@ export const BlockNoteContent = ({
return storedEditor;
}
// TODO decrypt doc.content
//localStorage.getItem('KEY');
const docId = 'uuid-du-doc';
const purpose = `doc:${docId}`;
const key = e2eClient.findKeyByPurpose(purpose);
if (!key) {
alert('probleme de key');
// return;
} else {
const decryptedMessage = e2eClient.decrypt(
doc.content,
key.keychainFingerprint,
);
console.log('decryptedMessage', decryptedMessage);
}
return BlockNoteEditorCore.create({
// collaboration: {
// provider,
// fragment: provider.document.getXmlFragment('document-store'),
// user: {
// name: userData?.email || 'Anonymous',
// color: randomColor(),
// },
// },
collaboration: {
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
color: randomColor(),
},
},
uploadFile,
initialContent: JSON.parse(doc.content),
});
}, [doc.content, storedEditor, uploadFile]);
useSaveDoc(doc.id, provider.document, canSave, editor);
}, [provider, storedEditor, uploadFile, userData?.email]);
useEffect(() => {
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
useEffect(() => {
setHeadings(editor);
editor?.onEditorContentChange(() => {
setHeadings(editor);
});
return () => {
resetHeadings();
};
}, [editor, resetHeadings, setHeadings]);
return (
<Box $css={cssEditor}>
<Box $css={cssEditor(readOnly)}>
{isErrorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors causes={errorAttachment.cause} />
<TextErrors
causes={errorAttachment.cause}
canClose
$textAlign="left"
/>
</Box>
)}
<BlockNoteView
editor={editor}
formattingToolbar={false}
editable={doc.abilities.partial_update && !isVersion}
editable={!readOnly}
theme="light"
>
<BlockNoteToolbar />

View File

@@ -9,12 +9,10 @@ import {
NestBlockButton,
TextAlignButton,
UnnestBlockButton,
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
import React from 'react';
import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
return (
@@ -57,79 +55,3 @@ export const BlockNoteToolbar = () => {
/>
);
};
type Block = {
type: string;
text: string;
content: Block[];
};
function isBlock(block: Block): block is Block {
return (
block.content &&
isArray(block.content) &&
block.content.length > 0 &&
typeof block.type !== 'undefined'
);
}
const recursiveContent = (content: Block[], base: string = '') => {
let fullContent = base;
for (const innerContent of content) {
if (innerContent.type === 'text') {
fullContent += innerContent.text;
} else if (isBlock(innerContent)) {
fullContent = recursiveContent(innerContent.content, fullContent);
}
}
return fullContent;
};
/**
* Custom Formatting Toolbar Button to convert markdown to json.
*/
export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
forEach(blocks, async (block) => {
if (!isBlock(block as unknown as Block)) {
return;
}
try {
const fullContent = recursiveContent(
block.content as unknown as Block[],
);
const blockMarkdown =
await editor.tryParseMarkdownToBlocks(fullContent);
editor.replaceBlocks([block.id], blockMarkdown);
} catch (error) {
console.error('Error parsing Markdown:', error);
}
});
};
const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
if (!show || !editor.isEditable || !Components) {
return null;
}
return (
<Components.FormattingToolbar.Button
mainTooltip="Convert Markdown"
onClick={handleConvertMarkdown}
>
M
</Components.FormattingToolbar.Button>
);
}

View File

@@ -5,19 +5,16 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { Panel } from '@/components/Panel';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { Summary, useDocSummaryStore } from '@/features/docs/doc-summary';
import {
VersionList,
Versions,
useDocVersion,
useDocVersionStore,
} from '@/features/docs/doc-versioning/';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
import { useHeadingStore } from '../stores';
import { BlockNoteEditor } from './BlockNoteEditor';
import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps {
doc: Doc;
@@ -27,10 +24,9 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
const { isPanelSummaryOpen, setIsPanelSummaryOpen } = useDocSummaryStore();
const { t } = useTranslation();
const { headings } = useHeadingStore();
const { isMobile } = useResponsiveStore();
const isVersion = versionId && typeof versionId === 'string';
@@ -57,26 +53,24 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$margin={{ all: 'small', top: 'none' }}
$gap="1rem"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip;"
$position="relative"
>
<Card $padding="big" $css="flex:1;" $overflow="auto">
<Card
$padding={isMobile ? 'small' : 'big'}
$css="flex:1;"
$overflow="auto"
$position="relative"
>
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
)}
{!isMobile && <IconOpenPanelEditor headings={headings} />}
</Card>
{doc.abilities.versions_list && isPanelVersionOpen && (
<Panel title={t('VERSIONS')} setIsPanelOpen={setIsPanelVersionOpen}>
<VersionList doc={doc} />
</Panel>
)}
{isPanelSummaryOpen && (
<Panel title={t('SUMMARY')} setIsPanelOpen={setIsPanelSummaryOpen}>
<Summary doc={doc} />
</Panel>
)}
<PanelEditor doc={doc} headings={headings} />
</Box>
</>
);

View File

@@ -0,0 +1,84 @@
import '@blocknote/mantine/style.css';
import {
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
type Block = {
type: string;
text: string;
content: Block[];
};
function isBlock(block: Block): block is Block {
return (
block.content &&
isArray(block.content) &&
block.content.length > 0 &&
typeof block.type !== 'undefined'
);
}
const recursiveContent = (content: Block[], base: string = '') => {
let fullContent = base;
for (const innerContent of content) {
if (innerContent.type === 'text') {
fullContent += innerContent.text;
} else if (isBlock(innerContent)) {
fullContent = recursiveContent(innerContent.content, fullContent);
}
}
return fullContent;
};
/**
* Custom Formatting Toolbar Button to convert markdown to json.
*/
export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
forEach(blocks, async (block) => {
if (!isBlock(block as unknown as Block)) {
return;
}
try {
const fullContent = recursiveContent(
block.content as unknown as Block[],
);
const blockMarkdown =
await editor.tryParseMarkdownToBlocks(fullContent);
editor.replaceBlocks([block.id], blockMarkdown);
} catch (error) {
console.error('Error parsing Markdown:', error);
}
});
};
const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
if (!show || !editor.isEditable || !Components) {
return null;
}
return (
<Components.FormattingToolbar.Button
mainTooltip="Convert Markdown"
onClick={handleConvertMarkdown}
>
M
</Components.FormattingToolbar.Button>
);
}

View File

@@ -0,0 +1,207 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { usePanelEditorStore } from '../stores';
import { HeadingBlock } from '../types';
interface PanelProps {
doc: Doc;
headings: HeadingBlock[];
}
export const PanelEditor = ({
doc,
headings,
}: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore();
const { isPanelTableContentOpen, setIsPanelTableContentOpen, isPanelOpen } =
usePanelEditorStore();
return (
<Card
$width="100%"
$maxWidth="20rem"
$position={isMobile ? 'absolute' : 'sticky'}
$height="100%"
$hasTransition="slow"
$css={`
top: 0vh;
right: 0;
transform: translateX(0%);
flex: 1;
margin-left: 1rem;
${
!isPanelOpen &&
`
transform: translateX(200%);
opacity: 0;
flex: 0;
margin-left: 0rem;
max-width: 0rem;
`
}
`}
aria-label={t('Document panel')}
aria-hidden={!isPanelOpen}
>
<Box
$overflow="inherit"
$position="sticky"
$hasTransition="slow"
$css={`
top: 0;
opacity: ${isPanelOpen ? '1' : '0'};
`}
$maxHeight="99vh"
>
{isMobile && <IconOpenPanelEditor headings={headings} />}
<Box
$direction="row"
$justify="space-between"
$align="center"
$position="relative"
$background={colorsTokens()['primary-400']}
$margin={{ bottom: 'tiny' }}
$radius="4px 4px 0 0"
>
<Box
$background="white"
$position="absolute"
$height="100%"
$width={doc.abilities.versions_list ? '50%' : '100%'}
$hasTransition="slow"
$css={`
border-top: 2px solid ${colorsTokens()['primary-600']};
border-radius: 0 4px 0 0;
${
isPanelTableContentOpen
? `
transform: translateX(0);
border-radius: 4px 0 0 0;
`
: `transform: translateX(100%);`
}
`}
/>
<BoxButton
$minWidth={doc.abilities.versions_list ? '50%' : '100%'}
onClick={() => setIsPanelTableContentOpen(true)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Table of content')}
</Text>
</BoxButton>
{doc.abilities.versions_list && (
<BoxButton
$minWidth="50%"
onClick={() => setIsPanelTableContentOpen(false)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Versions')}
</Text>
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
</Box>
</Card>
);
};
interface IconOpenPanelEditorProps {
headings: HeadingBlock[];
}
export const IconOpenPanelEditor = ({ headings }: IconOpenPanelEditorProps) => {
const { t } = useTranslation();
const { setIsPanelOpen, isPanelOpen, setIsPanelTableContentOpen } =
usePanelEditorStore();
const [hasBeenOpen, setHasBeenOpen] = useState(isPanelOpen);
const { isMobile } = useResponsiveStore();
const setClosePanel = () => {
setHasBeenOpen(true);
setIsPanelOpen(!isPanelOpen);
};
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenOpen && !isMobile) {
setIsPanelTableContentOpen(true);
setIsPanelOpen(true);
setHasBeenOpen(true);
}
}, [
headings,
setIsPanelTableContentOpen,
setIsPanelOpen,
hasBeenOpen,
isMobile,
]);
// If open from the doc header we set the state as well
useEffect(() => {
if (isPanelOpen && !hasBeenOpen) {
setHasBeenOpen(true);
}
}, [hasBeenOpen, isPanelOpen]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelOpen(false);
};
}, [setIsPanelOpen]);
return (
<IconBG
iconName="menu_open"
aria-label={isPanelOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={10}
$hasTransition="slow"
$css={`
cursor: pointer;
right: 0rem;
top: 0.1rem;
transform: rotate(${isPanelOpen ? '180deg' : '0deg'});
user-select: none;
${hasBeenOpen ? 'display:flex;' : 'display: none;'}
`}
$position="absolute"
onClick={setClosePanel}
$radius="2px"
/>
);
};

View File

@@ -1,24 +1,17 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/features/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
import { isFirefox } from '@/utils/userAgent';
import { toBase64 } from '../utils';
import { useE2ESDKClient } from '@socialgouv/e2esdk-react';
const useSaveDoc = (
docId: string,
doc: Y.Doc,
canSave: boolean,
editor: BlockNoteEditor,
) => {
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
});
const e2eClient = useE2ESDKClient();
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),
);
@@ -64,32 +57,14 @@ const useSaveDoc = (
}, [canSave, hasChanged]);
const saveDoc = useCallback(() => {
const newDoc = JSON.stringify(editor.document);
//const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
// TODO encode the content
const docId = 'uuid-du-doc';
const purpose = `doc:${docId}`;
const key = e2eClient.findKeyByPurpose(purpose);
if (!key) {
alert('probleme de key');
return;
}
const encrypted = e2eClient.encrypt(newDoc, key.keychainFingerprint);
console.log('encrypted', encrypted);
// todo
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
setInitialDoc(newDoc);
updateDoc({
id: docId,
content: newDoc,
});
}, [docId, editor?.document, updateDoc]);
}, [doc, docId, updateDoc]);
const timeout = useRef<NodeJS.Timeout>();
const router = useRouter();
@@ -113,10 +88,7 @@ const useSaveDoc = (
* if he wants to leave the page, by adding the popup, we let the time to the
* request to be sent, and intercepted by the service worker (for the offline part).
*/
const isFirefox =
navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
if (typeof e !== 'undefined' && e.preventDefault && isFirefox) {
if (typeof e !== 'undefined' && e.preventDefault && isFirefox()) {
e.preventDefault();
}
};

View File

@@ -1,3 +1,4 @@
export * from './components';
export * from './stores';
export * from './types';
export * from './utils';

View File

@@ -1 +1,3 @@
export * from './useDocStore';
export * from './useHeadingStore';
export * from './usePanelEditorStore';

View File

@@ -6,6 +6,8 @@ import { create } from 'zustand';
import { providerUrl } from '@/core';
import { Base64 } from '@/features/docs/doc-management';
import { blocksToYDoc } from '../utils';
interface DocStore {
provider: HocuspocusProvider;
editor?: BlockNoteEditor;
@@ -26,9 +28,18 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
guid: storeId,
});
// if (initialDoc) {
// Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
// }
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
} else {
const initialDocContent = [
{
type: 'heading',
content: '',
},
];
blocksToYDoc(initialDocContent, doc);
}
const provider = new HocuspocusProvider({
url: providerUrl(storeId),

View File

@@ -0,0 +1,43 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { HeadingBlock } from '../types';
const recursiveTextContent = (content: HeadingBlock['content']): string => {
if (!content) {
return '';
}
return content.reduce((acc, content) => {
if (content.type === 'text') {
return acc + content.text;
} else if (content.type === 'link') {
return acc + recursiveTextContent(content.content);
}
return acc;
}, '');
};
export interface UseHeadingStore {
headings: HeadingBlock[];
setHeadings: (editor: BlockNoteEditor) => void;
resetHeadings: () => void;
}
export const useHeadingStore = create<UseHeadingStore>((set) => ({
headings: [],
setHeadings: (editor) => {
const headingBlocks = editor?.document
.filter((block) => block.type === 'heading')
.map((block) => ({
...block,
contentText: recursiveTextContent(
block.content as unknown as HeadingBlock['content'],
),
})) as unknown as HeadingBlock[];
set(() => ({ headings: headingBlocks }));
},
resetHeadings: () => set(() => ({ headings: [] })),
}));

View File

@@ -0,0 +1,19 @@
import { create } from 'zustand';
export interface UsePanelEditorStore {
isPanelOpen: boolean;
setIsPanelOpen: (isOpen: boolean) => void;
isPanelTableContentOpen: boolean;
setIsPanelTableContentOpen: (isOpen: boolean) => void;
}
export const usePanelEditorStore = create<UsePanelEditorStore>((set) => ({
isPanelOpen: false,
isPanelTableContentOpen: true,
setIsPanelTableContentOpen: (isPanelTableContentOpen) => {
set(() => ({ isPanelTableContentOpen }));
},
setIsPanelOpen: (isPanelOpen) => {
set(() => ({ isPanelOpen }));
},
}));

View File

@@ -1,3 +1,14 @@
export interface DocAttachment {
file: string;
}
export type HeadingBlock = {
id: string;
type: string;
text: string;
content: HeadingBlock[];
contentText: string;
props: {
level: number;
};
};

View File

@@ -1,3 +1,5 @@
import * as Y from 'yjs';
export const randomColor = () => {
const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
@@ -26,3 +28,20 @@ function hslToHex(h: number, s: number, l: number) {
export const toBase64 = (
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
) => Buffer.from(str).toString('base64');
type BasicBlock = {
type: string;
content: string;
};
export const blocksToYDoc = (blocks: BasicBlock[], doc: Y.Doc) => {
const xmlFragment = doc.getXmlFragment('document-store');
blocks.forEach((block) => {
const xmlElement = new Y.XmlElement(block.type);
if (block.content) {
xmlElement.insert(0, [new Y.XmlText(block.content)]);
}
xmlFragment.push([xmlElement]);
});
};

View File

@@ -1,5 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import React, { Fragment, useState } from 'react';
import React, { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, StyledLink, Text } from '@/components';
@@ -8,12 +7,14 @@ import {
Doc,
Role,
currentDocRole,
useTransRole,
useTrans,
} from '@/features/docs/doc-management';
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { Versions } from '@/features/docs/doc-versioning';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { DocTagPublic } from './DocTagPublic';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
@@ -25,20 +26,25 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const { formatDate } = useDate();
const transRole = useTransRole();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { transRole } = useTrans();
const { isMobile, isSmallMobile } = useResponsiveStore();
return (
<>
<Card
$margin="small"
$margin={isMobile ? 'tiny' : 'small'}
aria-label={t('It is the card information about the document.')}
>
<Box $padding="small" $direction="row" $align="center">
<Box
$padding={isMobile ? 'tiny' : 'small'}
$direction="row"
$align="center"
>
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$variation="600"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$hasTransition
@@ -52,39 +58,39 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'small' }}
$margin={{ horizontal: 'tiny' }}
/>
<Box $gap="1rem" $direction="row">
<Text
as="h2"
$align="center"
$margin={{ all: 'none', left: 'tiny' }}
>
{doc.title}
</Text>
{versionId && (
<Button
onClick={() => {
setIsModalVersionOpen(true);
}}
size="small"
>
{t('Restore this version')}
</Button>
)}
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$wrap="wrap"
$align="center"
>
<DocTitle doc={doc} />
<DocToolBox doc={doc} versionId={versionId} />
</Box>
<DocToolBox doc={doc} />
</Box>
<Box
$direction="row"
$align="center"
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$css="border-top:1px solid #eee"
$padding={{ horizontal: 'big', vertical: 'tiny' }}
$padding={{
horizontal: isMobile ? 'tiny' : 'big',
vertical: 'tiny',
}}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
$position="relative"
>
<Box $direction="row" $align="center" $gap="0.5rem 2rem" $wrap="wrap">
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$gap="0.5rem 2rem"
$wrap="wrap"
>
<DocTagPublic doc={doc} />
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
@@ -111,13 +117,6 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
</Text>
</Box>
</Card>
{isModalVersionOpen && versionId && (
<ModalVersion
onClose={() => setIsModalVersionOpen(false)}
docId={doc.id}
versionId={versionId}
/>
)}
</>
);
};

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, LinkReach } from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
interface DocTagPublicProps {
doc: Doc;
@@ -11,6 +12,7 @@ interface DocTagPublicProps {
export const DocTagPublic = ({ doc }: DocTagPublicProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const { isSmallMobile } = useResponsiveStore();
if (doc?.link_reach !== LinkReach.PUBLIC) {
return null;
@@ -24,6 +26,8 @@ export const DocTagPublic = ({ doc }: DocTagPublicProps) => {
$padding="xtiny"
$radius="3px"
$size="s"
$position={isSmallMobile ? 'absolute' : 'initial'}
$css={isSmallMobile ? 'right: 10px;' : ''}
>
{t('Public')}
</Text>

View File

@@ -0,0 +1,172 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import {
Tooltip,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useHeadingStore } from '@/features/docs/doc-editor';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
useTrans,
useUpdateDoc,
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { isFirefox } from '@/utils/userAgent';
const DocTitleStyle = createGlobalStyle`
.c__tooltip {
padding: 4px 6px;
}
`;
interface DocTitleProps {
doc: Doc;
}
export const DocTitle = ({ doc }: DocTitleProps) => {
const { isMobile } = useResponsiveStore();
if (!doc.abilities.partial_update) {
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'tiny' }}
$size={isMobile ? 'h4' : 'h2'}
>
{doc.title}
</Text>
);
}
return <DocTitleInput doc={doc} />;
};
const DocTitleInput = ({ doc }: DocTitleProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const { toast } = useToastProvider();
const { untitledDocument } = useTrans();
const isUntitled = titleDisplay === untitledDocument;
const { headings } = useHeadingStore();
const headingText = headings?.[0]?.contentText;
const debounceRef = useRef<NodeJS.Timeout>();
const { isMobile } = useResponsiveStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(data) {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
}
},
});
const handleTitleSubmit = useCallback(
(inputText: string) => {
let sanitizedTitle = inputText.trim();
sanitizedTitle = sanitizedTitle.replace(/(\r\n|\n|\r)/gm, '');
// When blank we set to untitled
if (!sanitizedTitle) {
sanitizedTitle = untitledDocument;
setTitleDisplay(sanitizedTitle);
}
// If mutation we update
if (sanitizedTitle !== doc.title) {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
}
updateDoc({ id: doc.id, title: sanitizedTitle });
}
},
[doc.id, doc.title, untitledDocument, updateDoc],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleTitleSubmit(e.currentTarget.textContent || '');
}
};
const handleOnClick = () => {
if (isUntitled) {
setTitleDisplay('');
}
};
useEffect(() => {
if ((!debounceRef.current && !isUntitled) || !headingText) {
return;
}
setTitleDisplay(headingText);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleTitleSubmit(headingText);
debounceRef.current = undefined;
}, 3000);
}, [isUntitled, handleTitleSubmit, headingText]);
return (
<>
<DocTitleStyle />
<Tooltip content={t('Rename')} placement="top">
<Box
as="h2"
$radius="4px"
$padding={{ horizontal: 'tiny', vertical: '4px' }}
$margin="none"
contentEditable={isFirefox() ? 'true' : 'plaintext-only'}
onClick={handleOnClick}
onBlurCapture={(e) =>
handleTitleSubmit(e.currentTarget.textContent || '')
}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
$color={
isUntitled
? colorsTokens()['greyscale-200']
: colorsTokens()['greyscale-text']
}
$css={`
${isUntitled && 'font-style: italic;'}
cursor: text;
font-size: ${isMobile ? '1.2rem' : '1.5rem'};
transition: box-shadow 0.5s, border-color 0.5s;
border: 1px dashed transparent;
&:hover {
border-color: rgba(0, 123, 255, 0.25);
border-style: dashed;
}
&:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
`}
>
{titleDisplay}
</Box>
</Tooltip>
</>
);
};

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