mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 23:22:15 +02:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2035a256f5 | ||
|
|
c94f26c8b9 | ||
|
|
fc2f14b3f4 | ||
|
|
6dd1697915 | ||
|
|
79e899c301 | ||
|
|
2194301716 | ||
|
|
0348894ab8 | ||
|
|
9b17d8bea1 | ||
|
|
69d6b6f934 | ||
|
|
6c106374fa | ||
|
|
af039d045d | ||
|
|
4c9caf09ba | ||
|
|
3fd02adbec | ||
|
|
90dac3cd15 | ||
|
|
d0307ee6d9 | ||
|
|
09d02b7ced | ||
|
|
56a26d9663 | ||
|
|
42f809f6d4 | ||
|
|
7d64c82987 | ||
|
|
6252227bb6 | ||
|
|
e9ac393a8f | ||
|
|
5b1745f991 | ||
|
|
0e55bf5c43 | ||
|
|
9f66f73501 | ||
|
|
c3da28b07f | ||
|
|
b035b96dec | ||
|
|
9623ac4141 | ||
|
|
c8edbd285b | ||
|
|
016597d5a2 | ||
|
|
52dea8fa2f | ||
|
|
0a37a8ea6d | ||
|
|
c1404ef904 | ||
|
|
2c0fce61df | ||
|
|
bbe9b6b6cf | ||
|
|
23231563c9 | ||
|
|
d75c8668c5 | ||
|
|
f266232b5a | ||
|
|
a8362e8e88 | ||
|
|
e4dfae1905 | ||
|
|
a09e740648 | ||
|
|
5ee6a43f08 | ||
|
|
8bd83cbfcd | ||
|
|
bc14d1d0f8 | ||
|
|
526e649f06 | ||
|
|
ac40eb8f7c | ||
|
|
c750cf10a8 | ||
|
|
4f4951cdcd | ||
|
|
50891afd05 | ||
|
|
cbb6fc740a | ||
|
|
31c3dd6119 | ||
|
|
15700ddd8d |
6
.github/workflows/docker-hub.yml
vendored
6
.github/workflows/docker-hub.yml
vendored
@@ -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
22
.github/workflows/helmfile-linter.yaml
vendored
Normal 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"
|
||||
4
.github/workflows/impress-frontend.yml
vendored
4
.github/workflows/impress-frontend.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/impress.yml
vendored
6
.github/workflows/impress.yml
vendored
@@ -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]
|
||||
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
45
src/backend/core/tests/test_api_config.py
Normal file
45
src/backend/core/tests/test_api_config.py
Normal 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",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -55,4 +55,5 @@ urlpatterns = [
|
||||
]
|
||||
),
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
src/backend/locale/de_DE/LC_MESSAGES/django.mo
Normal file
BIN
src/backend/locale/de_DE/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
349
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal file
349
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal 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 ""
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
160
src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
Normal file
160
src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://test.jest
|
||||
NEXT_PUBLIC_THEME=test-theme
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
6
src/frontend/apps/impress/src/api/config.ts
Normal file
6
src/frontend/apps/impress/src/api/config.ts
Normal 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}/`;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { baseApiUrl } from '@/core';
|
||||
|
||||
import { baseApiUrl } from './config';
|
||||
import { getCSRFToken } from './utils';
|
||||
|
||||
interface FetchAPIInit extends RequestInit {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './APIError';
|
||||
export * from './config';
|
||||
export * from './fetchApi';
|
||||
export * from './helpers';
|
||||
export * from './types';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
49
src/frontend/apps/impress/src/core/config/ConfigProvider.tsx
Normal file
49
src/frontend/apps/impress/src/core/config/ConfigProvider.tsx
Normal 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;
|
||||
};
|
||||
1
src/frontend/apps/impress/src/core/config/api/index.ts
Normal file
1
src/frontend/apps/impress/src/core/config/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './useConfig';
|
||||
35
src/frontend/apps/impress/src/core/config/api/useConfig.tsx
Normal file
35
src/frontend/apps/impress/src/core/config/api/useConfig.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
2
src/frontend/apps/impress/src/core/config/hooks/index.ts
Normal file
2
src/frontend/apps/impress/src/core/config/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useMediaUrl';
|
||||
export * from './useCollaborationUrl';
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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 : '')
|
||||
);
|
||||
};
|
||||
3
src/frontend/apps/impress/src/core/config/index.ts
Normal file
3
src/frontend/apps/impress/src/core/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './api/';
|
||||
export * from './ConfigProvider';
|
||||
export * from './hooks';
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './AppProvider';
|
||||
export * from './auth';
|
||||
export * from './conf';
|
||||
export * from './config';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
import { tokens } from './cunningham-tokens';
|
||||
import useCunninghamTheme from './useCunninghamTheme';
|
||||
|
||||
export { tokens, useCunninghamTheme };
|
||||
export * from './cunningham-tokens';
|
||||
export * from './useCunninghamTheme';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './useSaveDoc';
|
||||
export * from './useUploadFile';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './useDocStore';
|
||||
export * from './useEditorStore';
|
||||
export * from './useHeadingStore';
|
||||
export * from './usePanelEditorStore';
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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('/');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './hooks';
|
||||
export * from './stores';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useDocStore';
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export const InvitationItem = ({
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{doc.abilities.manage_accesses && (
|
||||
{doc.abilities.accesses_manage && (
|
||||
<Box $margin={isSmallMobile ? 'auto' : ''}>
|
||||
<Button
|
||||
color="tertiary-text"
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
31
src/frontend/apps/impress/src/services/Crisp.tsx
Normal file
31
src/frontend/apps/impress/src/services/Crisp.tsx
Normal 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();
|
||||
};
|
||||
1
src/frontend/apps/impress/src/services/index.ts
Normal file
1
src/frontend/apps/impress/src/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Crisp';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user