Compare commits

..

51 Commits

Author SHA1 Message Date
Anthony LC
2035a256f5 🔖(patch) release 1.8.1
Fixed:
🐛(frontend) link not clickable and flickering firefox
2024-11-27 17:17:35 +01:00
Anthony LC
c94f26c8b9 ⬇️(SW) workbox-webpack-plugin to 7.1.0
A recent update to the workbox-webpack-plugin
package seems to introduce strange behavior.
Better to downgrade in waiting that it is more stable.
2024-11-27 16:50:11 +01:00
Anthony LC
fc2f14b3f4 🐛(frontend) link not clickable and flickering firefox
The link in the read mode was not clickable anymore,
it was due to a attempt to not display the cursor
of anonymous users.
We changes the way to do it by rendering our own cursor,
when a user is anonymous we don't render the cursor.
By rendering our own cursor we fixed another problem,
the cursor was flickering when the user was typing
at the end of the line on the firefox browser.
2024-11-27 16:50:11 +01:00
Anthony LC
6dd1697915 🐛(frontend) use hook useTranslation
Sentry highlitghted a few errors about the
function "t" not being defined. Better to get
it from the hook useTranslation.
2024-11-27 16:50:11 +01:00
Anthony LC
79e899c301 ♻️(frontend) add hooks useUploadFile
Move upload file logic to hooks useUploadFile.
It will be more readable and easy to reuse.
2024-11-27 16:50:11 +01:00
Anthony LC
2194301716 🔖(minor) release 1.8.0
Added:
- 🌐(backend) add german translation
- 🌐(frontend) Add German translation
- (frontend) Add a broadcast store
- (backend) whitelist pod's IP address
- (backend) config endpoint
- (frontend) config endpoint
- (frontend) add sentry
- (frontend) add crisp chatbot

Changed:
- 🚸(backend) improve users similarity search and
sort results
- ♻️(frontend) simplify stores
- (frontend) update $css Box props type to add
styled components RuleSet
- (CI) trivy continue on error

Fixed:
- 🔧(backend) fix logging for docker and make it
configurable by envar
- 🦺(backend) add comma to sub regex
- 🐛(editor) collaborative user tag hidden when
read only
- 🐛(frontend) users have view access when revoked
- 🐛(frontend) fix placeholder editable when double clicks
2024-11-27 09:47:42 +01:00
Anthony LC
0348894ab8 🐛(frontend) fix rerender title with broadcasting
The title was not rerendering on other clients
when the title was updated by one client.
This commit fixes the issue.
We set a min width for the title as well, it
will fix the issue with strange behavior when
people were double clicking.
2024-11-26 18:15:18 +01:00
Anthony LC
9b17d8bea1 🚨(frontend) remove Crisp warning
Remove the Crisp warning that was being displayed
on the console in our environments.
2024-11-26 18:15:18 +01:00
Anthony LC
69d6b6f934 (CI) trivy continue on error
Trivy is extremly flaky,
we need to continue on error to avoid
blocking the pipeline.
We still keep the check, to see if there are any
vulnerabilities, but we don't want to block
the pipeline.
2024-11-26 11:53:11 +01:00
Anthony LC
6c106374fa (frontend) add crisp chatbot
Integrate Crisp chatbot for immediate user support access.

This enables real-time interaction, enhancing user experience
by providing quick assistance.
2024-11-25 17:06:02 +01:00
Anthony LC
af039d045d 🔧(backend) add CRISP_WEBSITE_ID setting
Add setting CRISP_WEBSITE_ID. This setting is
used to configure the Crisp chat widget.
It will be available to the conf endpoint, to
be used by the frontend.
2024-11-25 17:06:02 +01:00
Anthony LC
4c9caf09ba ⬆️(CI) upgrade upload-artifact@v3 to v4
Upload artifact v3 is deprecated soon, so we need
to upgrade it to v4.
2024-11-25 13:16:06 +01:00
Anthony LC
3fd02adbec 💄(frontend) remove Blocknote fix
A recent upgrade of Blocknote to 0.19.2 fixed
a issue that we were solving. We removed our
fix as it is no longer needed.
2024-11-25 13:16:06 +01:00
Anthony LC
90dac3cd15 🏷️(frontend) update typescript types
We updated typescript to 5.7.2.
Some types were deprecated and we had to update them.
2024-11-25 13:16:06 +01:00
Anthony LC
d0307ee6d9 ⬆️(dependencies) update js dependencies 2024-11-25 13:16:06 +01:00
Anthony LC
09d02b7ced 🚚(frontend) move conf api urls to api folder
Previous refacto let only the api urls in the conf
file, so better to move it to the api folder.
2024-11-25 09:46:14 +01:00
Anthony LC
56a26d9663 🧪(CI) pass trivy security
The trivy security blocked the deploiement.
It says that we have a vulnerability because
we are using the cross-spawn@7.0.3 package, but
we are not, we are using the cross-spawn@7.0.6 package.
We will bypass this security check in the docker-hub.yml
file in waiting for another solution.
2024-11-25 09:46:14 +01:00
Anthony LC
42f809f6d4 ♻️(frontend) get collaboration server url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the collaboration
server url, we refacto to remove the frontend env
occurences and to adapt with the new way to get the
collaboration server url.
2024-11-25 09:46:14 +01:00
Anthony LC
7d64c82987 ♻️(frontend) get media url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the media url,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the media url.
2024-11-25 09:46:14 +01:00
Anthony LC
6252227bb6 ♻️(frontend) get theme from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the theme,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the theme.
2024-11-25 09:46:14 +01:00
Anthony LC
e9ac393a8f (frontend) add sentry
In order to monitor the frontend, we are adding
sentry.
2024-11-25 09:46:14 +01:00
Anthony LC
5b1745f991 (frontend) add config provider
Add a ConfigProvider to the frontend to provide
configuration to the app.
The configuration is loaded from the config
endpoint, we will use react-query cache capabilities
to store the configuration.
2024-11-25 09:46:14 +01:00
Anthony LC
0e55bf5c43 🔒️(helm) allow server host and whitelist pod IP for health checks
In a Kubernetes environment, we need to whitelist the pod's IP address
to allow health checks to pass. This ensures that Kubernetes liveness and
readiness probes can access the application to verify its health.
2024-11-22 13:01:55 +01:00
Samuel Paccoud - DINUM
9f66f73501 🔧(backend) fix logging for docker and make it configurable by envar
Logs were not made to the console so it was hard to debug in k8s.
We propose a ready made logging configuration that sends everything
to the console and allow adjusting log levels with environment
variables.
2024-11-20 11:51:20 +01:00
Samuel Paccoud - DINUM
c3da28b07f ️(helm) bring back helm chart
This is a revert of 1da5a removing actual deployments and keeping
only the dev environment in Tilt.

The clean-up was a bit heavy handed. We should keep the Helm
chart to the development repository and move away only the
deployment configuration.
2024-11-20 11:51:20 +01:00
Anthony LC
b035b96dec ⬆️(CI) bump python version in backend test
We were testing the backend with python 3.10.0, but
actually the backend was running with python 3.12.6.
We bump the python version in the backend test to match
the running version of the backend.
2024-11-20 09:51:08 +01:00
Anthony LC
9623ac4141 🩹(backend) get current release from pyproject.toml
"get_release" was returning NA, we fixed it by
getting the version from pyproject.toml, to do so we
use tomllib
Since tomllib is a native library from Python 3.11,
we bump the required version to 3.11 on the pyproject.toml.
2024-11-20 09:51:08 +01:00
Anthony LC
c8edbd285b 🔧(backend) add FRONTEND_THEME setting
The frontend need to know the theme to be used,
so we need to add a new setting to the backend,
in order to expose this value to the frontend.
2024-11-20 09:51:08 +01:00
Anthony LC
016597d5a2 🔧(backend) add COLLABORATION_SERVER_URL setting
The frontend need to know the collab server url,
so we need to add a new setting to the backend,
in order to expose this value to the frontend.
If the setting is not defined, the frontend current
domain will be used as the base url.
In production this setting do not need to be defined
since we have nginx capturing the ws requests,
but in development we need to define it to target
the collaboration server.
2024-11-20 09:51:08 +01:00
Anthony LC
52dea8fa2f 🔧(backend) add MEDIA_BASE_URL setting
The frontend need to know the base url for the
media files, so we need to add a new setting
to the backend, in order to expose this value
to the frontend.
If the setting is not defined, the frontend current
domain will be used as the base url.
In production this setting do not need to be defined
since we have nginx capturing the media requests,
but in development we need to define it to target
the nginx server.
2024-11-20 09:51:08 +01:00
Anthony LC
0a37a8ea6d (backend) add public endpoint /api/v1.0/config/
Add public endpoint /api/v1.0/config/ to
share some public configuration values.
2024-11-20 09:51:08 +01:00
Anthony LC
c1404ef904 ⬆️(dependencies) bump cross-spawn from 7.0.3 to 7.0.6
Bumps cross-spawn from 7.0.3 to 7.0.6.

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-20 09:24:34 +01:00
renovate[bot]
2c0fce61df ⬆️(dependencies) update js dependencies 2024-11-18 17:25:16 +01:00
Nathan Panchout
bbe9b6b6cf (frontend) add styledCss props to Box component
In order to facilitate DX and not to use a string in the code for the css.
We add the $styledCss props to the Box component.
This object comes from Styled component
2024-11-15 10:33:56 +01:00
Anthony LC
23231563c9 💄(frontend) text color on Blocknote code block options
The options for the code block in the Blocknote
editor was not visible. We changed the text color
to make it visible.
A fix will be made to the code block options in the
next blocknote release.
2024-11-14 17:36:11 +01:00
Anthony LC
d75c8668c5 🚨(frontend) blocknote cast to Dictionnary
The last Blocknote upgrade (0.19.0) gives us
a warning with the dictionnary typing.
We cast it to the correct type to remove the warning.
2024-11-14 17:36:11 +01:00
Anthony LC
f266232b5a ♻️(frontend) use next/router instead of next/navigation
The last upgrade of next.js gives a warning
when we were using next/navigation with the
pages router.
This commit fixes this issue.
2024-11-14 17:36:11 +01:00
Anthony LC
a8362e8e88 ⬆️(dependencies) update js dependencies 2024-11-14 17:36:11 +01:00
Anthony LC
e4dfae1905 ♻️(frontend) simplify useDocStore
We moved the editor store to its own store in the previous
commit. This change allow us to simplify useDocStore.
2024-11-13 15:25:29 +01:00
Anthony LC
a09e740648 ♻️(frontend) move editor store to useEditorStore
Previous changes migrated the editor store to
doc-management, we move it back doc-editor and
simplify it.
2024-11-13 15:25:29 +01:00
Anthony LC
5ee6a43f08 (frontend) add useBroadcastStore
Add the useBroadcastStore.
It will give us the ability to easily
broadcast actions to all connected clients.

In this case, we requery the doc to everyone
when a change relative to the doc rights is made.
2024-11-09 10:21:24 +01:00
Anthony LC
8bd83cbfcd 🚚(frontend) move useDocStore to doc-management
We want to make more accessible the doc store
to every feature, so we move it to the
doc-management folder.
2024-11-09 10:21:24 +01:00
Anthony LC
bc14d1d0f8 🐛(editor) collaborative user tag hidden when read only
When the user was in read-only mode, the user
tag could be displayed when they were touching the
doc. This commit fixes this issue.
We add the full name instead of the email in the
cursor tag.
2024-11-08 12:01:23 +01:00
Anthony LC
526e649f06 🦺(backend) add comma to sub regex
Some sub have comma, the regex was a bit too strict
and didn't allow it, this commit fixes that.
2024-11-08 10:53:53 +01:00
Anthony LC
ac40eb8f7c 🌐(frontend) add German translation
- Add the german translation to Docs
- Add the german language to the frontend
language picker
2024-11-07 15:58:49 +01:00
lindenb1
c750cf10a8 🌐(backend) adding de_DE translation for the backend
This adds German translation to the backend and
adjusts the .po file sequence by priority.

Signed-off-by: lindenb1 <linden@b1-systems.de>
2024-11-07 11:49:41 +01:00
Samuel Paccoud - DINUM
4f4951cdcd 🚸(backend) improve users similarity search and sort results
In some edge cases, the domain part the email addresse is
longer than the name part. Users searches by email similarity
then return a lot of unsorted results.

We can improve this by being more demanding on similarity when
the query looks like an email. Sorting results by the similarity
score is also an obvious improvement.

At the moment, we still think it is good to propose results with
a weak similarity on the name part because we want to avoid
as much as possible creating duplicate users by inviting one of
is many emails, a user who is already in our database.

Fixes 399
2024-11-06 08:27:18 +01:00
Anthony LC
50891afd05 🔖(minor) release 1.7.0
Added:
- 📝Contributing.md
- 🌐(frontend) add localization to editor
- Public and restricted doc editable
- (frontend) Add full name if available
- (backend) Add view accesses ability

Changed:
- ♻️(frontend) avoid documents indexing in search engine
- ♻️(frontend) list accesses if user has abilities
- 👔(backend) doc restricted by default

Fixed:
- 🐛(backend) require right to manage document
  accesses to see invitations
- 🐛(i18n) same frontend and backend language using
  shared cookies
- 🐛(frontend) add default toolbar buttons
- 🐛(frontend) throttle error correctly display

Removed:
- 🔥(helm) remove infra related codes
2024-10-25 14:41:48 +02:00
Anthony LC
cbb6fc740a 👔(backend) doc restricted by default
By default a created document was in "authenticated"
mode, we switch to "restricted" by default.
2024-10-25 14:25:48 +02:00
Anthony LC
31c3dd6119 🛂(frontend) show member list depend ability
We integrate the new ability "accesses_view" that
tells if a user can view the accesses of a document.
2024-10-24 17:31:34 +02:00
Samuel Paccoud - DINUM
15700ddd8d (backend) add new ability on document "accesses_view"
We need this ability in the frontend to know whether we should try
to display the list of users who have document accesses. If this
ability is False (e.g for anonymous users), we should only show
the link reach and link role when clicking on the "Share" button.
2024-10-24 17:31:34 +02:00
136 changed files with 7820 additions and 3430 deletions

View File

@@ -8,6 +8,9 @@ on:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
@@ -52,6 +55,7 @@ jobs:
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -102,6 +106,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -153,6 +158,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6

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

@@ -99,7 +99,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-chromium-report
@@ -133,7 +133,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-other-report

View File

@@ -107,7 +107,9 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -199,7 +201,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Install development dependencies
run: pip install --user .[dev]

View File

@@ -9,6 +9,43 @@ and this project adheres to
## [Unreleased]
## [1.8.1] - 2024-11-27
## Fixed
🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
- 🌐(backend) add german translation #259
- 🌐(frontend) Add German translation #255
- ✨(frontend) Add a broadcast store #387
- ✨(backend) whitelist pod's IP address #443
- ✨(backend) config endpoint #425
- ✨(frontend) config endpoint #424
- ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450
## Changed
- 🚸(backend) improve users similarity search and sort results #391
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
- ✅(CI) trivy continue on error #453
## Fixed
- 🔧(backend) fix logging for docker and make it configurable by envar #427
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## Added
@@ -17,10 +54,13 @@ and this project adheres to
- 🌐(frontend) add localization to editor #368
- ✨Public and restricted doc editable #357
- ✨(frontend) Add full name if available #380
- ✨(backend) Add view accesses ability #376
## Changed
- ♻️(frontend) list accesses if user has abilities #376
- ♻️(frontend) avoid documents indexing in search engine #372
- 👔(backend) doc restricted by default #388
## Fixed
@@ -235,7 +275,9 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.8.1...main
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1

View File

@@ -4,6 +4,12 @@ DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=admin
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
@@ -21,6 +27,7 @@ STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStora
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=impress
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
@@ -44,3 +51,9 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_SERVER_URL=ws://localhost:4444
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -6,6 +6,7 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
@@ -25,11 +26,13 @@ from rest_framework import (
mixins,
pagination,
status,
views,
viewsets,
)
from rest_framework import (
response as drf_response,
)
from rest_framework.permissions import AllowAny
from core import enums, models
from core.services.ai_services import AIService
@@ -156,8 +159,21 @@ class UserViewSet(
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
# For performance reasons we filter first by similarity, which relies on an index,
# then only calculate precise similarity scores for sorting purposes
queryset = queryset.filter(email__trigram_word_similar=query)
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", query)
)
# When the query only is on the name part, we should try to make many proposals
# But when the query looks like an email we should only propose serious matches
threshold = 0.6 if "@" in query else 0.1
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity"
)
return queryset
@decorators.action(
@@ -872,3 +888,31 @@ class InvitationViewset(
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
)
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"COLLABORATION_SERVER_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
return drf_response.Response(dict_settings)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-10-25 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]

View File

@@ -130,17 +130,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -336,7 +336,7 @@ class Document(BaseModel):
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.AUTHENTICATED,
default=LinkReachChoices.RESTRICTED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
@@ -496,7 +496,8 @@ class Document(BaseModel):
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
can_get_versions = bool(roles)
# Anonymous users should also not see document accesses
has_role = bool(roles)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
@@ -511,19 +512,20 @@ class Document(BaseModel):
can_get = bool(roles)
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"destroy": RoleChoices.OWNER in roles,
"link_configuration": is_owner_or_admin,
"manage_accesses": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
@@ -679,7 +681,7 @@ class Template(BaseModel):
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"accesses_manage": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,

View File

@@ -47,6 +47,7 @@ def test_api_documents_create_authenticated_success():
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.link_reach == "restricted"
assert document.accesses.filter(role="owner", user=user).exists()

View File

@@ -21,13 +21,14 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"destroy": False,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -78,13 +79,14 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",

View File

@@ -22,7 +22,7 @@ def test_api_templates_retrieve_anonymous_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -68,7 +68,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,

View File

@@ -0,0 +1,45 @@
"""
Test config API endpoints in the Impress core app.
"""
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_SERVER_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_SERVER_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"SENTRY_DSN": "https://sentry.test/123",
}

View File

@@ -69,6 +69,48 @@ def test_api_users_list_query_email():
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by similarity"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="jane.smith@example.gouv.fr")
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
factories.UserFactory(email="david.jones@example.gouv.fr")
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
response = client.get(
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(alice.id)]
response = client.get(
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users

View File

@@ -83,13 +83,14 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"destroy": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": False,
"update": False,
@@ -116,13 +117,14 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -149,13 +151,14 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -171,13 +174,14 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": True,
"link_configuration": True,
"invite_owner": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -192,13 +196,14 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": True,
"invite_owner": False,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -216,13 +221,14 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -242,13 +248,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -269,13 +276,14 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,

View File

@@ -62,7 +62,7 @@ def test_models_templates_get_abilities_anonymous_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -76,7 +76,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -90,7 +90,7 @@ def test_models_templates_get_abilities_authenticated_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -104,7 +104,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -119,7 +119,7 @@ def test_models_templates_get_abilities_owner():
"destroy": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -133,7 +133,7 @@ def test_models_templates_get_abilities_administrator():
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -150,7 +150,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": True,
"generate_document": True,
}
@@ -167,7 +167,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -185,7 +185,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}

View File

@@ -55,4 +55,5 @@ urlpatterns = [
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]

View File

@@ -10,8 +10,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -27,19 +28,12 @@ DATA_DIR = os.path.join("/", "data")
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
return "NA" # Default: not available
@@ -56,7 +50,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* DJANGO_SENTRY_DSN
* SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -104,6 +98,9 @@ class Base(Configuration):
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
@@ -237,6 +234,7 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -371,7 +369,22 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Collaboration
COLLABORATION_SERVER_URL = values.Value(
None, environ_name="COLLABORATION_SERVER_URL", environ_prefix=None
)
# Frontend
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
# Crisp
CRISP_WEBSITE_ID = values.Value(
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -481,6 +494,42 @@ class Base(Configuration):
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": values.Value(
"ERROR",
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
environ_prefix=None,
),
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -576,23 +625,6 @@ class Development(Base):
class Test(Base):
"""Test environment settings"""
LOGGING = values.DictValue(
{
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"impress": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
@@ -623,7 +655,13 @@ class Production(Base):
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
# Add allowed host from environment variables.
# The machine hostname is added by default,
# it makes the application pingable by a load balancer on the same machine by example
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True

Binary file not shown.

View File

@@ -0,0 +1,349 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \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: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
msgid "Personal info"
msgstr "Persönliche Angaben"
#: core/admin.py:34
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:46
msgid "Important dates"
msgstr "Wichtige Termine"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: 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 "Leser"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Bearbeiter"
#: core/models.py:71
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:72
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:80
msgid "Restricted"
msgstr "Eingeschränkt"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:86
msgid "Public"
msgstr "Öffentlich"
#: 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 "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt: %(document)s"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: 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:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s hat ein Dokument mit Ihnen geteilt! "
#: 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 hat Sie als %(role)s zum folgenden Dokument eingeladen: "
#: 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 "Öffnen"
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und Zusammenarbeiten an Dokumenten im Team. "
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Bereitgestellt von La Suite Numérique"
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt"
#: 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 hat Sie als %(role)s zum folgenden Dokument eingeladen:"
#: 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, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und gemeinsamen Arbeiten an Dokumenten im Team."
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -345,11 +345,14 @@ msgstr ""
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -345,11 +345,14 @@ msgstr "Proposé par La Suite Numérique"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.7.0"
version = "1.8.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,13 +17,13 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.12",
]
description = "An application to print markdown to pdf from a set of managed templates."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.12"
dependencies = [
"boto3==1.35.44",
"Brotli==1.1.0",
@@ -127,6 +127,7 @@ select = [
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]

View File

@@ -61,18 +61,9 @@ FROM impress AS impress-builder
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG Y_PROVIDER_URL
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}

View File

@@ -144,7 +144,7 @@ export const mockedDocument = async (page: Page, json: object) => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,

View File

@@ -0,0 +1,160 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
const config = {
CRISP_WEBSITE_ID: null,
COLLABORATION_SERVER_URL: 'ws://localhost:4444',
ENVIRONMENT: 'development',
FRONTEND_THEME: 'dsfr',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'French'],
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
SENTRY_DSN: null,
};
test.describe('Config', () => {
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
expect(await response.json()).toStrictEqual(config);
});
test('it checks that sentry is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
SENTRY_DSN: 'https://sentry.io/123',
},
});
} else {
await route.continue();
}
});
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
const consoleMessage = page.waitForEvent('console', {
timeout: 5000,
predicate: (msg) => msg.text().includes(invalidMsg),
});
await page.goto('/');
expect((await consoleMessage).text()).toContain(invalidMsg);
});
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({
page,
browserName,
}) => {
await page.goto('/');
await createDoc(page, 'doc-media', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
await expect(image).toBeVisible();
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
test('it checks that collaboration server is configured from config endpoint', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
});
await page.goto('/');
const randomDoc = await createDoc(
page,
'doc-collaboration',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
CRISP_WEBSITE_ID: '1234',
},
});
} else {
await route.continue();
}
});
await page.goto('/');
await expect(
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
});

View File

@@ -215,7 +215,7 @@ test.describe('Doc Editor', () => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,

View File

@@ -303,7 +303,7 @@ test.describe('Documents Grid mobile', () => {
attachment_upload: true,
destroy: true,
link_configuration: true,
manage_accesses: true,
accesses_manage: true,
partial_update: true,
retrieve: true,
update: true,

View File

@@ -45,7 +45,7 @@ test.describe('Doc Header', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,
@@ -177,12 +177,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if administrator', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: true, // Means admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
@@ -247,12 +248,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if editor', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
@@ -324,12 +326,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if reader', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -489,7 +492,7 @@ test.describe('Documents Header mobile', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,

View File

@@ -40,20 +40,20 @@ test.describe('Doc Visibility', () => {
name: 'Visibility',
});
await expect(selectVisibility.getByText('Authenticated')).toBeVisible();
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Restricted',
name: 'Authenticated',
})
.click();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await selectVisibility.click();
@@ -87,26 +87,6 @@ test.describe('Doc Visibility: Restricted', () => {
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Restricted',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
@@ -133,26 +113,6 @@ test.describe('Doc Visibility: Restricted', () => {
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Restricted',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
@@ -182,20 +142,6 @@ test.describe('Doc Visibility: Restricted', () => {
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Restricted',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
@@ -389,6 +335,26 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
@@ -421,6 +387,26 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
@@ -435,10 +421,20 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -457,6 +453,26 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page.getByRole('button', { name: 'Share' }).click();
@@ -483,9 +499,19 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
});

View File

@@ -24,6 +24,18 @@ test.describe('Language', () => {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.7.0",
"version": "1.8.1",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.48.1",
"@playwright/test": "1.49.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",

View File

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

View File

@@ -1,4 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
NEXT_PUBLIC_SW_DEACTIVATED=true

View File

@@ -1,2 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_THEME=test-theme

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.7.0",
"version": "1.8.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,37 +19,39 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.7",
"@hocuspocus/provider": "2.14.0",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.59.15",
"i18next": "23.16.2",
"@sentry/nextjs": "8.40.0",
"@tanstack/react-query": "5.61.3",
"crisp-sdk-web": "1.0.25",
"i18next": "24.0.0",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.15",
"next": "15.0.3",
"react": "*",
"react-aria-components": "1.4.1",
"react-aria-components": "1.5.0",
"react-dom": "*",
"react-i18next": "15.0.3",
"react-select": "5.8.1",
"react-i18next": "15.1.1",
"react-select": "5.8.3",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.0"
"zustand": "5.0.1"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.59.15",
"@tanstack/react-query-devtools": "5.61.3",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.2",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.12",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.11",
"@types/react": "18.3.12",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -63,7 +65,7 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.95.0",
"webpack": "5.96.1",
"workbox-webpack-plugin": "7.1.0"
}
}

View File

@@ -5,7 +5,7 @@ import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/navigation', () => ({
jest.mock('next/router', () => ({
useRouter() {
return {
push: jest.fn(),
@@ -13,6 +13,12 @@ jest.mock('next/navigation', () => ({
},
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
setUser: jest.fn(),
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });

View File

@@ -0,0 +1,6 @@
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;

View File

@@ -1,5 +1,4 @@
import { baseApiUrl } from '@/core';
import { baseApiUrl } from './config';
import { getCSRFToken } from './utils';
interface FetchAPIInit extends RequestInit {

View File

@@ -1,4 +1,5 @@
export * from './APIError';
export * from './config';
export * from './fetchApi';
export * from './helpers';
export * from './types';

View File

@@ -1,6 +1,6 @@
import { ComponentPropsWithRef, ReactHTML } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
import { CSSProperties, RuleSet } from 'styled-components/dist/types';
import {
MarginPadding,
@@ -15,7 +15,7 @@ export interface BoxProps {
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$color?: CSSProperties['color'];
$css?: string;
$css?: string | RuleSet<object>;
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
@@ -73,7 +73,7 @@ export const Box = styled('div')<BoxProps>`
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
${({ $css }) => $css && `${$css};`}
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`}
${({ $effect }) => {
let effect;

View File

@@ -1,4 +1,5 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
@@ -26,7 +27,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
$background="none"
$margin="none"
$padding="none"
$css={`
$css={css`
cursor: pointer;
border: none;
outline: none;

View File

@@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
@@ -15,7 +16,7 @@ export const Card = ({
<Box
$background="white"
$radius="4px"
$css={`
$css={css`
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
border: 1px solid ${colorsTokens()['card-border']};
${$css}

View File

@@ -7,6 +7,7 @@ import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { Auth } from './auth/';
import { ConfigProvider } from './config/';
/**
* QueryClient:
@@ -39,7 +40,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
</CunninghamProvider>
</QueryClientProvider>
);

View File

@@ -0,0 +1,40 @@
import { Crisp } from 'crisp-sdk-web';
import fetchMock from 'fetch-mock';
import { useAuthStore } from '../useAuthStore';
jest.mock('crisp-sdk-web', () => ({
...jest.requireActual('crisp-sdk-web'),
Crisp: {
isCrispInjected: jest.fn().mockReturnValue(true),
setTokenId: jest.fn(),
user: {
setEmail: jest.fn(),
},
session: {
reset: jest.fn(),
},
},
}));
describe('useAuthStore', () => {
afterEach(() => {
jest.clearAllMocks();
fetchMock.restore();
});
it('checks support session is terminated when logout', () => {
window.$crisp = true;
Object.defineProperty(window, 'location', {
value: {
...window.location,
replace: jest.fn(),
},
writable: true,
});
useAuthStore.getState().logout();
expect(Crisp.session.reset).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { baseApiUrl } from '@/core/conf';
import { baseApiUrl } from '@/api';
import { terminateCrispSession } from '@/services';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
@@ -42,6 +43,7 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
terminateCrispSession();
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page and we are not authenticated

View File

@@ -1,18 +0,0 @@
export const mediaUrl = () =>
process.env.NEXT_PUBLIC_MEDIA_URL ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
export const providerUrl = (docId: string) => {
const base =
process.env.NEXT_PUBLIC_Y_PROVIDER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${docId}`;
};

View File

@@ -0,0 +1,49 @@
import { Loader } from '@openfun/cunningham-react';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
return;
}
setSentry(conf.SENTRY_DSN, conf.ENVIRONMENT);
}, [conf?.SENTRY_DSN, conf?.ENVIRONMENT, setSentry]);
useEffect(() => {
if (!conf?.FRONTEND_THEME) {
return;
}
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
if (!conf?.CRISP_WEBSITE_ID) {
return;
}
configureCrispSession(conf.CRISP_WEBSITE_ID);
}, [conf?.CRISP_WEBSITE_ID]);
if (!conf) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
return children;
};

View File

@@ -0,0 +1 @@
export * from './useConfig';

View File

@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
interface ConfigResponse {
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
ENVIRONMENT: string;
COLLABORATION_SERVER_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
MEDIA_BASE_URL?: string;
SENTRY_DSN?: string;
}
export const getConfig = async (): Promise<ConfigResponse> => {
const response = await fetchAPI(`config/`);
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
}
return response.json() as Promise<ConfigResponse>;
};
export const KEY_CONFIG = 'config';
export function useConfig() {
return useQuery<ConfigResponse, APIError, ConfigResponse>({
queryKey: [KEY_CONFIG],
queryFn: () => getConfig(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1,2 @@
export * from './useMediaUrl';
export * from './useCollaborationUrl';

View File

@@ -0,0 +1,15 @@
import { useConfig } from '../api';
export const useCollaborationUrl = (room?: string) => {
const { data: conf } = useConfig();
if (!room) {
return;
}
const base =
conf?.COLLABORATION_SERVER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${room}`;
};

View File

@@ -0,0 +1,10 @@
import { useConfig } from '../api';
export const useMediaUrl = () => {
const { data: conf } = useConfig();
return (
conf?.MEDIA_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
);
};

View File

@@ -0,0 +1,3 @@
export * from './api/';
export * from './ConfigProvider';
export * from './hooks';

View File

@@ -1,3 +1,3 @@
export * from './AppProvider';
export * from './auth';
export * from './conf';
export * from './config';

View File

@@ -1,12 +1,6 @@
import useCunninghamTheme from '../useCunninghamTheme';
import { useCunninghamTheme } from '../useCunninghamTheme';
describe('<useCunninghamTheme />', () => {
it('has the theme from NEXT_PUBLIC_THEME', () => {
const { theme } = useCunninghamTheme.getState();
expect(theme).toBe('test-theme');
});
it('has the dsfr logo correctly set', () => {
const { themeTokens, setTheme } = useCunninghamTheme.getState();
setTheme('dsfr');

View File

@@ -1,4 +1,2 @@
import { tokens } from './cunningham-tokens';
import useCunninghamTheme from './useCunninghamTheme';
export { tokens, useCunninghamTheme };
export * from './cunningham-tokens';
export * from './useCunninghamTheme';

View File

@@ -6,22 +6,25 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
type ColorsTokens = Tokens['theme']['colors'];
type ComponentTokens = Tokens['components'];
type Theme = 'default' | 'dsfr';
export type Theme = keyof typeof tokens.themes;
interface AuthStore {
theme: Theme;
theme: string;
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
componentTokens: () => ComponentTokens;
}
const useCunninghamTheme = create<AuthStore>((set, get) => {
export const useCunninghamTheme = create<AuthStore>((set, get) => {
const currentTheme = () =>
merge(tokens.themes['default'], tokens.themes[get().theme]) as Tokens;
merge(
tokens.themes['default'],
tokens.themes[get().theme as keyof typeof tokens.themes],
) as Tokens;
return {
theme: (process.env.NEXT_PUBLIC_THEME as Theme) || 'dsfr',
theme: 'dsfr',
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
@@ -30,5 +33,3 @@ const useCunninghamTheme = create<AuthStore>((set, get) => {
},
};
});
export default useCunninghamTheme;

View File

@@ -20,9 +20,6 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_MEDIA_URL?: string;
NEXT_PUBLIC_Y_PROVIDER_URL?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
NEXT_PUBLIC_THEME?: string;
}
}

View File

@@ -14,14 +14,13 @@ import { useTranslation } from 'react-i18next';
import { isAPIError } from '@/api';
import { Box, Text } from '@/components';
import { useDocOptions } from '@/features/docs/doc-management/';
import { useDocOptions, useDocStore } from '@/features/docs/doc-management/';
import {
AITransformActions,
useDocAITransform,
useDocAITranslate,
} from '../api/';
import { useDocStore } from '../stores';
type LanguageTranslate = {
value: string;

View File

@@ -1,21 +1,19 @@
import { locales } from '@blocknote/core';
import { Dictionary, locales } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import React, { useCallback, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, TextErrors } from '@/components';
import { mediaUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Version } from '@/features/docs/doc-versioning/';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import { useUploadFile } from '../hook';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore, useHeadingStore } from '../stores';
import { useEditorStore, useHeadingStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
@@ -28,9 +26,6 @@ const cssEditor = (readonly: boolean) => `
padding-right: 30px;
${readonly && `padding-left: 30px;`}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
@@ -68,70 +63,32 @@ const cssEditor = (readonly: boolean) => `
`;
interface BlockNoteEditorProps {
doc: Doc;
version?: Version;
}
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
const { createProvider, docsStore } = useDocStore();
const storeId = version?.id || doc.id;
const initialContent = version?.content || doc.content;
const provider = docsStore?.[storeId]?.provider;
useEffect(() => {
if (!provider || provider.document.guid !== storeId) {
createProvider(storeId, initialContent);
}
}, [createProvider, initialContent, provider, storeId]);
if (!provider) {
return null;
}
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
};
interface BlockNoteContentProps {
doc: Doc;
provider: HocuspocusProvider;
storeId: string;
}
export const BlockNoteContent = ({
export const BlockNoteEditor = ({
doc,
provider,
storeId,
}: BlockNoteContentProps) => {
}: BlockNoteEditorProps) => {
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
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 { i18n } = useTranslation();
const lang = i18n.language;
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const ret = await createDocAttachment({
docId: doc.id,
body,
});
return `${mediaUrl()}${ret.file}`;
},
[createDocAttachment, doc.id],
);
const collabName =
userData?.full_name ||
userData?.email ||
(readOnly ? 'Reader' : t('Anonymous'));
const editor = useCreateBlockNote(
{
@@ -139,19 +96,48 @@ export const BlockNoteContent = ({
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
name: collabName,
color: randomColor(),
},
/**
* We re-use the blocknote code to render the cursor but we:
* - fix rendering issue with Firefox
* - We don't want to show the cursor when anonymous users
*/
renderCursor: (user: { color: string; name: string }) => {
const cursor = document.createElement('span');
if (user.name === 'Reader') {
return cursor;
}
cursor.classList.add('collaboration-cursor__caret');
cursor.setAttribute('style', `border-color: ${user.color}`);
const label = document.createElement('span');
label.classList.add('collaboration-cursor__label');
label.setAttribute('style', `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);
cursor.insertBefore(label, null);
return cursor;
},
},
dictionary: locales[lang as keyof typeof locales],
dictionary: locales[lang as keyof typeof locales] as Dictionary,
uploadFile,
},
[provider, uploadFile, userData?.email, lang],
[collabName, lang, provider, uploadFile],
);
useEffect(() => {
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useEffect(() => {
setHeadings(editor);
@@ -167,7 +153,7 @@ export const BlockNoteContent = ({
return (
<Box $css={cssEditor(readOnly)}>
{isErrorAttachment && (
{errorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors
causes={errorAttachment.cause}
@@ -178,7 +164,7 @@ export const BlockNoteContent = ({
)}
<BlockNoteView
editor={storedEditor ?? editor}
editor={editor}
formattingToolbar={false}
editable={!readOnly}
theme="light"

View File

@@ -1,13 +1,13 @@
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCollaborationUrl } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
@@ -32,6 +32,13 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const { colorsTokens } = useCunninghamTheme();
const { providers } = useDocStore();
const provider = providers?.[doc.id];
if (!provider) {
return null;
}
return (
<>
<DocHeader doc={doc} versionId={versionId as Versions['version_id']} />
@@ -66,7 +73,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
<BlockNoteEditor doc={doc} storeId={doc.id} provider={provider} />
)}
{!isMobile && <IconOpenPanelEditor headings={headings} />}
</Card>
@@ -91,12 +98,25 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
docId: doc.id,
versionId,
});
const { createProvider, providers } = useDocStore();
const collaborationUrl = useCollaborationUrl(versionId);
const navigate = useNavigate();
const { replace } = useRouter();
useEffect(() => {
if (!version?.id || !collaborationUrl) {
return;
}
const provider = providers?.[version.id];
if (!provider || provider.document.guid !== version.id) {
createProvider(collaborationUrl, version.id, version.content);
}
}, [createProvider, providers, version, collaborationUrl]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
void replace(`/404`);
return null;
}
@@ -124,5 +144,11 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
return <BlockNoteEditor doc={doc} version={version} />;
const provider = providers?.[version.id];
if (!provider) {
return null;
}
return <BlockNoteEditor doc={doc} storeId={version.id} provider={provider} />;
};

View File

@@ -127,9 +127,7 @@ export const PanelEditor = ({
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{isPanelTableContentOpen && <TableContent headings={headings} />}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}

View File

@@ -0,0 +1,2 @@
export * from './useSaveDoc';
export * from './useUploadFile';

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { useMediaUrl } from '@/core/config';
import { useCreateDocAttachment } from '../api';
export const useUploadFile = (docId: string) => {
const mediaUrl = useMediaUrl();
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const ret = await createDocAttachment({
docId,
body,
});
return `${mediaUrl}${ret.file}`;
},
[createDocAttachment, docId, mediaUrl],
);
return {
uploadFile,
isErrorAttachment,
errorAttachment,
};
};

View File

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

View File

@@ -1,74 +0,0 @@
import { BlockNoteEditor } from '@blocknote/core';
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
import { providerUrl } from '@/core';
import { Base64, Doc } from '@/features/docs/doc-management';
import { blocksToYDoc } from '../utils';
interface DocStore {
provider: HocuspocusProvider;
editor?: BlockNoteEditor;
}
export interface UseDocStore {
currentDoc?: Doc;
docsStore: {
[storeId: string]: DocStore;
};
createProvider: (storeId: string, initialDoc: Base64) => HocuspocusProvider;
setStore: (storeId: string, props: Partial<DocStore>) => void;
setCurrentDoc: (doc: Doc | undefined) => void;
}
export const useDocStore = create<UseDocStore>((set, get) => ({
currentDoc: undefined,
docsStore: {},
createProvider: (storeId: string, initialDoc: Base64) => {
const doc = new Y.Doc({
guid: storeId,
});
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),
name: storeId,
document: doc,
});
get().setStore(storeId, { provider });
return provider;
},
setStore: (storeId, props) => {
set(({ docsStore }, ...store) => {
return {
...store,
docsStore: {
...docsStore,
[storeId]: {
...docsStore[storeId],
...props,
},
},
};
});
},
setCurrentDoc: (doc) => {
set({ currentDoc: doc });
},
}));

View File

@@ -0,0 +1,14 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
export interface UseEditorstore {
editor?: BlockNoteEditor;
setEditor: (editor: BlockNoteEditor | undefined) => void;
}
export const useEditorStore = create<UseEditorstore>((set) => ({
editor: undefined,
setEditor: (editor) => {
set({ editor });
},
}));

View File

@@ -1,5 +1,3 @@
import * as Y from 'yjs';
export const randomColor = () => {
const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
@@ -25,23 +23,5 @@ function hslToHex(h: number, s: number, l: number) {
return `#${f(0)}${f(8)}${f(4)}`;
}
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]);
});
};
export const toBase64 = (str: Uint8Array) =>
Buffer.from(str).toString('base64');

View File

@@ -1,5 +1,6 @@
import React, { Fragment } from 'react';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Card, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -46,7 +47,11 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
$theme="primary"
$variation="600"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$css={css`
&:hover {
background-color: ${colorsTokens()['primary-100']};
}
`}
$hasTransition
$radius="5px"
$padding="tiny"

View File

@@ -18,7 +18,7 @@ import {
useTrans,
useUpdateDoc,
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { isFirefox } from '@/utils/userAgent';
interface DocTitleProps {
@@ -54,13 +54,17 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const headingText = headings?.[0]?.contentText;
const debounceRef = useRef<NodeJS.Timeout>();
const { isMobile } = useResponsiveStore();
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
listInvalideQueries: [KEY_LIST_DOC],
onSuccess(data) {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
}
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${data.id}`);
},
});
@@ -100,6 +104,10 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}
};
useEffect(() => {
setTitleDisplay(doc.title);
}, [doc.title]);
useEffect(() => {
if ((!debounceRef.current && !isUntitled) || !headingText) {
return;
@@ -125,6 +133,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
$radius="4px"
$padding={{ horizontal: 'tiny', vertical: '4px' }}
$margin="none"
$minWidth="200px"
contentEditable={isFirefox() ? 'true' : 'plaintext-only'}
onClick={handleOnClick}
onBlurCapture={(e) =>

View File

@@ -8,16 +8,18 @@ import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions } from '@/components';
import { useAuthStore } from '@/core';
import { useDocStore, usePanelEditorStore } from '@/features/docs/doc-editor/';
import {
useEditorStore,
usePanelEditorStore,
} from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
ModalShare,
} from '@/features/docs/doc-management';
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { ModalVersion, Versions } from '../../doc-versioning';
import { ModalPDF } from './ModalExport';
interface DocToolBoxProps {
@@ -35,13 +37,12 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = useResponsiveStore();
const { authenticated } = useAuthStore();
const { docsStore } = useDocStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown',
) => {
const editor = docsStore[doc.id]?.editor;
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
return;

View File

@@ -10,11 +10,11 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { useDocStore } from '@/features/docs/doc-editor/';
import { useEditorStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { useExport } from '../api/useExport';
@@ -27,11 +27,12 @@ interface ModalPDFProps {
}
export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
const { t } = useTranslation();
const { data: templates } = useTemplates({
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
});
const { toast } = useToastProvider();
const { docsStore } = useDocStore();
const { editor } = useEditorStore();
const {
mutate: createExport,
data: documentGenerated,
@@ -104,8 +105,6 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
return;
}
const editor = docsStore[doc.id].editor;
if (!editor) {
toast(t('No editor found'), VariantType.ERROR);
return;

View File

@@ -5,7 +5,7 @@ export interface Template {
abilities: {
destroy: boolean;
generate_document: boolean;
manage_accesses: boolean;
accesses_manage: boolean;
retrieve: boolean;
update: boolean;
partial_update: boolean;

View File

@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs';
import { Doc, KEY_DOC } from '@/features/docs/doc-management';
import { useBroadcastStore } from '@/stores';
export type UpdateDocLinkParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'link_role' | 'link_reach'>>;
@@ -37,14 +38,20 @@ export function useUpdateDocLink({
listInvalideQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data) => {
onSuccess: (data, variable) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
queryKey: [queryKey],
});
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.id}`);
onSuccess?.(data);
},
});

View File

@@ -6,11 +6,11 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useCunninghamTheme } from '@/cunningham/';
import { useRemoveDoc } from '../api/useRemoveDoc';
import IconDoc from '../assets/icon-doc.svg';
@@ -22,9 +22,10 @@ interface ModalRemoveDocProps {
}
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const router = useRouter();
const { push } = useRouter();
const {
mutate: removeDoc,
@@ -35,7 +36,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
router.push('/');
void push('/');
},
});

View File

@@ -3,7 +3,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Card, IconBG, SideModal, Text } from '@/components';
@@ -44,6 +44,7 @@ interface ModalShareProps {
}
export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
const { t } = useTranslation();
const { isMobile, isSmallMobile } = useResponsiveStore();
const width = isSmallMobile ? '100vw' : isMobile ? '90vw' : '70vw';
const { toast } = useToastProvider();
@@ -115,7 +116,7 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
</Box>
</Card>
<DocVisibility doc={doc} />
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<AddMembers
doc={doc}
currentRole={currentDocRole(doc.abilities)}
@@ -123,8 +124,12 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
)}
</Box>
<Box $minHeight="0">
<InvitationList doc={doc} />
<MemberList doc={doc} />
{doc.abilities.accesses_view && (
<>
<InvitationList doc={doc} />
<MemberList doc={doc} />
</>
)}
</Box>
</Box>
</SideModal>

View File

@@ -1,5 +1,6 @@
export * from './api';
export * from './components';
export * from './hooks';
export * from './stores';
export * from './types';
export * from './utils';

View File

@@ -0,0 +1 @@
export * from './useDocStore';

View File

@@ -0,0 +1,63 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
import { Base64, Doc, blocksToYDoc } from '@/features/docs/doc-management';
export interface UseDocStore {
currentDoc?: Doc;
providers: {
[storeId: string]: HocuspocusProvider;
};
createProvider: (
providerUrl: string,
storeId: string,
initialDoc: Base64,
) => HocuspocusProvider;
setProviders: (storeId: string, providers: HocuspocusProvider) => void;
setCurrentDoc: (doc: Doc | undefined) => void;
}
export const useDocStore = create<UseDocStore>((set, get) => ({
currentDoc: undefined,
providers: {},
createProvider: (providerUrl, storeId, initialDoc) => {
const doc = new Y.Doc({
guid: storeId,
});
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
} else {
const initialDocContent = [
{
type: 'heading',
content: '',
},
];
blocksToYDoc(initialDocContent, doc);
}
const provider = new HocuspocusProvider({
url: providerUrl,
name: storeId,
document: doc,
});
get().setProviders(storeId, provider);
return provider;
},
setProviders: (storeId, provider) => {
set(({ providers }) => ({
providers: {
...providers,
[storeId]: provider,
},
}));
},
setCurrentDoc: (doc) => {
set({ currentDoc: doc });
},
}));

View File

@@ -44,10 +44,11 @@ export interface Doc {
created_at: string;
updated_at: string;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
attachment_upload: true;
destroy: boolean;
link_configuration: boolean;
manage_accesses: boolean;
partial_update: boolean;
retrieve: boolean;
update: boolean;

View File

@@ -1,11 +1,30 @@
import * as Y from 'yjs';
import { Doc, Role } from './types';
export const currentDocRole = (abilities: Doc['abilities']): Role => {
return abilities.destroy
? Role.OWNER
: abilities.manage_accesses
: abilities.accesses_manage
? Role.ADMIN
: abilities.partial_update
? Role.EDITOR
: Role.READER;
};
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

@@ -2,22 +2,19 @@ import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { HeadingBlock, useDocStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { HeadingBlock, useEditorStore } from '@/features/docs/doc-editor';
import { useResponsiveStore } from '@/stores';
import { Heading } from './Heading';
interface TableContentProps {
doc: Doc;
headings: HeadingBlock[];
}
export const TableContent = ({ doc, headings }: TableContentProps) => {
const { docsStore } = useDocStore();
export const TableContent = ({ headings }: TableContentProps) => {
const { editor } = useEditorStore();
const { isMobile } = useResponsiveStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
// To highlight the first heading in the viewport

View File

@@ -6,13 +6,13 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, Text } from '@/components';
import { toBase64, useDocStore } from '@/features/docs/doc-editor';
import { Doc, useUpdateDoc } from '@/features/docs/doc-management';
import { toBase64 } from '@/features/docs/doc-editor';
import { Doc, useDocStore, useUpdateDoc } from '@/features/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
import { Versions } from '../types';
@@ -30,30 +30,27 @@ export const ModalVersion = ({
docId,
versionId,
}: ModalVersionProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const router = useRouter();
const { docsStore, setStore } = useDocStore();
const { push } = useRouter();
const { providers } = useDocStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
const onDisplaySuccess = () => {
toast(t('Version restored successfully'), VariantType.SUCCESS);
router.push(`/docs/${docId}`);
void push(`/docs/${docId}`);
};
if (!docsStore?.[docId]?.provider || !docsStore?.[versionId]?.provider) {
if (!providers?.[docId] || !providers?.[versionId]) {
onDisplaySuccess();
return;
}
setStore(docId, {
editor: undefined,
});
revertUpdate(
docsStore[docId].provider.document,
docsStore[docId].provider.document,
docsStore[versionId].provider.document,
providers[docId].document,
providers[docId].document,
providers[versionId].document,
);
onDisplaySuccess();
@@ -83,7 +80,7 @@ export const ModalVersion = ({
fullWidth
onClick={() => {
const newDoc = toBase64(
Y.encodeStateAsUpdate(docsStore?.[versionId]?.provider.document),
Y.encodeStateAsUpdate(providers?.[versionId].document),
);
updateDoc({

View File

@@ -1,6 +1,6 @@
import { Button } from '@openfun/cunningham-react';
import { t } from 'i18next';
import React, { PropsWithChildren, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -25,6 +25,7 @@ export const VersionItem = ({
link,
isActive,
}: VersionItemProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isDropOpen, setIsDropOpen] = useState(false);
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);

View File

@@ -1,5 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,12 +12,12 @@ import { DocsGrid } from './DocsGrid';
export const DocsGridContainer = () => {
const { t } = useTranslation();
const { untitledDocument } = useTrans();
const router = useRouter();
const { push } = useRouter();
const { isMobile } = useResponsiveStore();
const { mutate: createDoc } = useCreateDoc({
onSuccess: (doc) => {
router.push(`/docs/${doc.id}`);
void push(`/docs/${doc.id}`);
},
});

View File

@@ -112,7 +112,7 @@ export const InvitationItem = ({
}}
/>
</Box>
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<Box $margin={isSmallMobile ? 'auto' : ''}>
<Button
color="tertiary-text"

View File

@@ -5,11 +5,13 @@ import { User } from '@/core/auth';
import {
Access,
Doc,
KEY_DOC,
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list';
import { ContentLanguage } from '@/i18n/types';
import { useBroadcastStore } from '@/stores';
import { OptionType } from '../types';
@@ -53,9 +55,11 @@ export const createDocAccess = async ({
export function useCreateDocAccess() {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Access, APIError, CreateDocAccessParams>({
mutationFn: createDocAccess,
onSuccess: () => {
onSuccess: (_data, variable) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
@@ -65,6 +69,9 @@ export function useCreateDocAccess() {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.docId}`);
},
});
}

View File

@@ -170,14 +170,14 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending || !doc.abilities.manage_accesses}
disabled={isPending || !doc.abilities.accesses_manage}
/>
</Box>
<Box $css="flex: auto;">
<ChooseRole
key={resetKey}
currentRole={currentRole}
disabled={isPending || !doc.abilities.manage_accesses}
disabled={isPending || !doc.abilities.accesses_manage}
setRole={setSelectedRole}
/>
</Box>
@@ -189,7 +189,7 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
!selectedUsers.length ||
isPending ||
!selectedRole ||
!doc.abilities.manage_accesses
!doc.abilities.accesses_manage
}
onClick={() => void handleValidate()}
style={{ height: '100%', maxHeight: '55px' }}

View File

@@ -7,6 +7,7 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_DOC, KEY_LIST_DOC } from '@/features/docs/doc-management';
import { KEY_LIST_USER } from '@/features/docs/members/members-add';
import { useBroadcastStore } from '@/stores';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
@@ -39,6 +40,8 @@ type UseDeleteDocAccessOptions = UseMutationOptions<
export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<void, APIError, DeleteDocAccessProps>({
mutationFn: deleteDocAccess,
...options,
@@ -49,6 +52,10 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variables.docId}`);
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});

View File

@@ -11,6 +11,7 @@ import {
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { useBroadcastStore } from '@/stores';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
@@ -49,6 +50,8 @@ type UseUpdateDocAccessOptions = UseMutationOptions<
export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Access, APIError, UpdateDocAccessProps>({
mutationFn: updateDocAccess,
...options,
@@ -59,6 +62,10 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variables.docId}`);
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});

View File

@@ -5,7 +5,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -35,7 +35,7 @@ export const MemberItem = ({
const { isSmallMobile, screenWidth } = useResponsiveStore();
const [localRole, setLocalRole] = useState(role);
const { toast } = useToastProvider();
const router = useRouter();
const { push } = useRouter();
const { mutate: updateDocAccess, error: errorUpdate } = useUpdateDocAccess({
onSuccess: () => {
toast(t('The role has been updated'), VariantType.SUCCESS, {
@@ -55,13 +55,13 @@ export const MemberItem = ({
);
if (isMyself) {
router.push('/');
void push('/');
}
},
});
const isNotAllowed =
isOtherOwner || isLastOwner || !doc.abilities.manage_accesses;
isOtherOwner || isLastOwner || !doc.abilities.accesses_manage;
if (!access.user) {
return (
@@ -112,7 +112,7 @@ export const MemberItem = ({
}}
/>
</Box>
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<Box $margin={isSmallMobile ? 'auto' : ''}>
<Button
color="tertiary-text"
@@ -136,7 +136,7 @@ export const MemberItem = ({
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
</Box>
)}
{(isLastOwner || isOtherOwner) && doc.abilities.manage_accesses && (
{(isLastOwner || isOtherOwner) && doc.abilities.accesses_manage && (
<Box $margin={{ top: 'tiny' }}>
<Alert
canClose={false}

View File

@@ -1,5 +1,4 @@
import Image from 'next/image';
import React from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';

View File

@@ -194,16 +194,17 @@ export class ApiPlugin implements WorkboxPlugin {
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
abilities: {
accesses_manage: true,
accesses_view: true,
attachment_upload: true,
destroy: true,
link_configuration: true,
partial_update: true,
retrieve: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
attachment_upload: true,
},
accesses: [
{

View File

@@ -2,7 +2,7 @@ export type RequestData = {
url: string;
method?: string;
headers: Record<string, string>;
body?: ArrayBuffer;
body?: ArrayBufferLike;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
};
@@ -48,12 +48,12 @@ export class RequestSerializer {
return new RequestSerializer(requestData);
}
public static arrayBufferToString(buffer: ArrayBuffer) {
public static arrayBufferToString(buffer: ArrayBufferLike) {
const decoder = new TextDecoder();
return decoder.decode(buffer);
return decoder.decode(buffer as ArrayBuffer);
}
public static arrayBufferToJson<T>(buffer: ArrayBuffer) {
public static arrayBufferToJson<T>(buffer: ArrayBufferLike) {
const jsonString = RequestSerializer.arrayBufferToString(buffer);
return JSON.parse(jsonString) as T;
}
@@ -64,7 +64,9 @@ export class RequestSerializer {
}
public static objectToArrayBuffer(ob: Record<string, unknown>) {
return RequestSerializer.stringToArrayBuffer(JSON.stringify(ob));
return RequestSerializer.stringToArrayBuffer(
JSON.stringify(ob),
) as ArrayBuffer;
}
constructor(requestData: RequestData) {
@@ -85,7 +87,7 @@ export class RequestSerializer {
toRequest(): Request {
const { url, ...rest } = this._requestData;
return new Request(url, rest);
return new Request(url, { ...rest, body: rest.body as BodyInit });
}
clone(): RequestSerializer {

View File

@@ -1,6 +1,7 @@
export const LANGUAGES_ALLOWED: { [key: string]: string } = {
en: 'English',
fr: 'Français',
de: 'Deutsch',
};
export const LANGUAGE_COOKIE_NAME = 'docs_language';
export const BASE_LANGUAGE = 'en';

View File

@@ -1,4 +1,118 @@
{
"de": {
"translation": {
"\"{{email}}\" is already invited to the document.": "\"{{email}}\" ist bereits zum Dokument eingeladen.",
"Accessibility": "Barrierefreiheit",
"Accessibility statement": "Erklärung zur Barrierefreiheit",
"Address:": "Anschrift:",
"Administrator": "Administrator",
"Anyone on the internet with the link can view": "Für jeden im Internet mit diesem Link sichtbar",
"Are you sure you want to delete the document \"{{title}}\"?": "Sind Sie sicher, dass Sie das Dokument \"{{title}}\" löschen möchten?",
"Back to home page": "Zurück zur Startseite",
"Back to top": "Zurück nach oben",
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
"Cancel": "Abbrechen",
"Choose a role": "Wählen Sie eine Rolle",
"Close the modal": "Pop up schliessen",
"Close the panel": "Fenster schließen",
"Compliance status": "Konformitätsstatus",
"Confirm deletion": "Löschung bestätigen",
"Content modal to delete document": "Inhalts-Modal zum Löschen des Dokuments",
"Content modal to export the document": "Inhalte zum Exportieren des Dokuments",
"Copy link": "Link kopieren",
"Create a new document": "Neues Dokument erstellen",
"Created at": "Erstellt am",
"Current version": "Aktuelle Version",
"Delete document": "Dokument löschen",
"Delete the document": "Dokument löschen",
"Deleting the document \"{{title}}\"": "Lösche das Dokument \"{{title}}\"",
"Doc visibility card": "Dokumenten-Sichtbarkeitskarte",
"Docs": "Docs",
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Pages: Ihr neuer Begleiter für eine effiziente, intuitive und sichere Zusammenarbeit bei Dokumenten.",
"Document icon": "Dokumentensymbol",
"Document name": "Dokumentenname",
"Document panel": "Dokumenten-Panel",
"Document title updated successfully": "Titel des Dokuments erfolgreich aktualisiert",
"Documents": "Dokumente",
"Docx": "Docx",
"Download": "Herunterladen",
"E-mail:": "E-Mail:",
"Editor": "Editor",
"Export": "Exportieren",
"Export your document, it will be inserted in the selected template.": "Exportieren Sie Ihr Dokument, es wird in die gewählte Vorlage eingefügt.",
"Failed to add the member in the document.": "Fehler beim Hinzufügen des Mitglieds zum Dokument.",
"Failed to copy link": "Link konnte nicht kopiert werden",
"Failed to create the invitation for {{email}}.": "Fehler beim Erstellen der Einladung für {{email}}.",
"Find a member to add to the document": "Suchen Sie ein Mitglied, das dem Dokument hinzugefügt werden soll",
"Go to bottom": "Gehe nach unten",
"If a member is editing, his works can be lost.": "Wenn ein Mitglied editiert, können seine Änderungen verloren gehen.",
"Improvement and contact": "Verbesserungen und Kontakt",
"Invitation sent to {{email}}.": "Einladung an {{email}} gesendet.",
"Invite new members to {{title}}": "Neue Mitglieder zu {{title}} einladen",
"Invited": "Eingeladen",
"It is the card information about the document.": "Es handelt sich um die Karteninformationen zum Dokument.",
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Es scheint, dass die von Ihnen gesuchte Seite nicht existiert oder nicht korrekt angezeigt werden kann.",
"Language": "Sprache",
"Legal Notice": "Impressum",
"Legal notice": "Impressum",
"Link Copied !": "Link kopiert!",
"Login": "Anmelden",
"Logout": "Abmelden",
"Members": "Mitglieder",
"No editor found": "Kein Editor gefunden",
"Offline ?!": "Offline?!",
"Only for people with access": "Nur für Personen mit Zugriff",
"Open the document options": "Öffnen Sie die Dokumentoptionen",
"Open the panel": "Panel öffnen",
"Open the version options": "Öffnen Sie die Versionsoptionen",
"Ouch !": "Autsch!",
"Owner": "Besitzer",
"Owners:": "Besitzer:",
"PDF": "PDF",
"Personal data and cookies": "Personenbezogene Daten und Cookies",
"Public": "Öffentlich",
"Read only, you cannot edit document versions.": "Nur lesen: Sie können Dokumentenversionen nicht bearbeiten.",
"Read only, you cannot edit this document.": "Nur lesen: Sie können dieses Dokument nicht bearbeiten.",
"Reader": "Leser",
"Rename": "Umbenennen",
"Restore": "Wiederherstellen",
"Restore the version": "Version wiederherstellen",
"Restore this version": "Version wiederherstellen",
"Restore this version?": "Diese Version wiederherstellen?",
"Role": "Rolle",
"Search by email": "Nach E-Mail suchen",
"Share": "Teilen",
"Share modal": "Teilen-Modal",
"Something bad happens, please retry.": "Etwas ist schiefgelaufen, bitte versuchen Sie es erneut.",
"Table of content": "Inhaltsverzeichnis",
"Table of contents": "Inhaltsverzeichnis",
"Template": "Vorlage",
"The document has been deleted.": "Das Dokument wurde gelöscht.",
"The invitation has been removed.": "Die Einladung wurde zurückgenommen.",
"The member has been removed from the document": "Das Mitglied wurde aus dem Dokument entfernt",
"The role has been updated": "Die Rolle wurde aktualisiert",
"The role has been updated.": "Die Rolle wurde aktualisiert.",
"This accessibility statement applies to the site hosted on": "Diese Erklärung zur Barrierefreiheit gilt für die gehostete Seite",
"This site does not display a cookie consent banner, why?": "",
"Unless otherwise stated, all content on this site is under": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"Untitled document": "Unbenanntes Dokument",
"Updated at": "Aktualisiert am",
"User {{email}} added to the document.": "Benutzer {{email}} wurde dem Dokument hinzugefügt.",
"Validate": "Bestätigen",
"Version history": "Versionsverlauf",
"Version restored successfully": "Version erfolgreich wiederhergestellt",
"Versions": "Versionen",
"We didn't find a mail matching, try to be more accurate": "Wir haben keine übereinstimmende E-Mail gefunden, versuchen Sie genauer zu sein",
"We try to respond within 2 working days.": "Wir versuchen, innerhalb von 2 Arbeitstagen zu antworten.",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Sie sind der einzige Besitzer dieser Gruppe. Machen Sie ein anderes Mitglied zum Gruppenbesitzer, bevor Sie Ihre eigene Rolle ändern oder aus Ihrem Dokument entfernen können.",
"You cannot update the role or remove other owner.": "Sie können die Rolle nicht aktualisieren oder einen anderen Besitzer entfernen.",
"You don't have any document yet.": "Sie haben noch kein Dokument.",
"Your current document will revert to this version.": "Ihr aktuelles Dokument wird auf diese Version zurückgesetzt.",
"Your role": "Ihre Rolle",
"Your role:": "Ihre Rolle:",
"Your {{format}} was downloaded succesfully": "Ihr {{format}} wurde erfolgreich heruntergeladen"
}
},
"en": { "translation": {} },
"fr": {
"translation": {

View File

@@ -1,15 +1,17 @@
import { Loader } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import Head from 'next/head';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Box, Text } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { useCollaborationUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { DocEditor, useDocStore } from '@/features/docs';
import { useDoc } from '@/features/docs/doc-management';
import { DocEditor } from '@/features/docs/doc-editor';
import { KEY_DOC, useDoc, useDocStore } from '@/features/docs/doc-management';
import { MainLayout } from '@/layouts';
import { useBroadcastStore } from '@/stores';
import { NextPageWithLayout } from '@/types/next';
export function DocLayout() {
@@ -41,9 +43,12 @@ const DocPage = ({ id }: DocProps) => {
const { login } = useAuthStore();
const { data: docQuery, isError, error } = useDoc({ id });
const [doc, setDoc] = useState(docQuery);
const { setCurrentDoc } = useDocStore();
const navigate = useNavigate();
const { setCurrentDoc, createProvider, providers } = useDocStore();
const { setBroadcastProvider, addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const { replace } = useRouter();
const provider = providers?.[id];
const collaborationUrl = useCollaborationUrl(doc?.id);
useEffect(() => {
if (doc?.title) {
@@ -66,9 +71,38 @@ const DocPage = ({ id }: DocProps) => {
};
}, [docQuery, setCurrentDoc]);
useEffect(() => {
if (!doc?.id || !collaborationUrl) {
return;
}
let newProvider = provider;
if (!provider || provider.document.guid !== doc.id) {
newProvider = createProvider(collaborationUrl, doc.id, doc.content);
}
setBroadcastProvider(newProvider);
}, [createProvider, doc, provider, setBroadcastProvider, collaborationUrl]);
/**
* We add a broadcast task to reset the query cache
* when the document visibility changes.
*/
useEffect(() => {
if (!doc?.id) {
return;
}
addTask(`${KEY_DOC}-${doc.id}`, () => {
void queryClient.resetQueries({
queryKey: [KEY_DOC, { id: doc.id }],
});
});
}, [addTask, doc?.id, queryClient]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
void replace(`/404`);
return null;
}

View File

@@ -0,0 +1,31 @@
/**
* Configure Crisp chat for real-time support across all pages.
*/
import { Crisp } from 'crisp-sdk-web';
import { User } from '@/core';
export const initializeCrispSession = (user: User) => {
if (!Crisp.isCrispInjected()) {
return;
}
Crisp.setTokenId(`impress-${user.id}`);
Crisp.user.setEmail(user.email);
};
export const configureCrispSession = (websiteId: string) => {
if (Crisp.isCrispInjected()) {
return;
}
Crisp.configure(websiteId);
Crisp.setSafeMode(true);
};
export const terminateCrispSession = () => {
if (!Crisp.isCrispInjected()) {
return;
}
Crisp.setTokenId();
Crisp.session.reset();
};

View File

@@ -0,0 +1 @@
export * from './Crisp';

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