Compare commits

..

12 Commits

Author SHA1 Message Date
Anthony LC
9194bf5a90 🔖(patch) release 2.0.1
Fixed:
🐛(frontend) title copy break app
2025-01-17 11:58:55 +01:00
Anthony LC
dc63a5839e ⬇️(frontend) downgraded blocknote to 0.21.0
The last version of Blocknote (0.22.0) has a bug,
when we copy paste a title, the app sometimes crashes.
Better to downgrade to 0.21.0 until the bug is fixed.
2025-01-17 11:33:47 +01:00
Anthony LC
d406846986 🎨(frontend) format css blocknote editor
We use the "css" function of style components
to format correctly the blocknote editor css.
2025-01-17 11:33:47 +01:00
Nathan Panchout
e85b07021e 🐛(frontend) fix collaboration cursor
- The collaboration slider is not fully shown when a user is at the very
top of the document
2025-01-17 11:02:41 +01:00
Nathan Panchout
282200ac3d 🐛(frontend) hide the sharing method when you don't have the rights
- Added a new hook `useCopyDocLink` to handle copying document links to
the clipboard with success/error notifications.
- Updated the `DocToolBox`, `DocsGridActions`, and `DocShareModal`
components to utilize the new copy link feature.
- Enhanced tests to verify the functionality of the copy link button in
various scenarios.
- Adjusted visibility checks for sharing options based on user access
rights.
2025-01-17 11:02:41 +01:00
Anthony LC
de8dea20d5 🔖(major) release 2.0.0
Added:
- 🔧(backend) add option to configure list of
essential OIDC claims
- 🔧(helm) add option to disable default tls
setting by @dominikkaminski
- 💄(frontend) Add left panel
- 💄(frontend) add filtering to left panel
- (frontend) new share modal ui
- (frontend) add favorite feature

Changed:
- 🏗️(yjs-server) organize yjs server
- ♻️(frontend) better separation collaboration
process
- 💄(frontend) updating the header and leftpanel
for responsive
- 💄(frontend) update DocsGrid component
- 💄(frontend) update DocsGridOptions component
- 💄(frontend) update DocHeader ui
- 💄(frontend) update doc versioning ui
- 💄(frontend) update doc summary u

Fixed:
- 🐛(backend) fix create document via s2s
if sub unknown but email found
- 🐛(frontend) hide search and create doc
button if not authenticated
- 🐛(backend) race condition creation issue
2025-01-15 12:46:00 +01:00
Anthony LC
342fc2ab59 ✏️(backend) fix read_only_fields is_favorite
is_favorite has a typo error.
This commit fixes it.
2025-01-15 12:13:40 +01:00
Anthony LC
b8132ef393 🐛(backend) creation race condition
3 requests we able to create a document:
- POST document request
- GET collaboration-auth
- GET media-auth

If the 2 last were faster than the first, a
document was created without the necessary
informations.
2025-01-15 12:13:40 +01:00
Nathan Panchout
2ede746d8a (frontend) hide search and create doc button if not logged
- Added visibility checks for 'search' and 'New doc' buttons in the
document visibility tests.
- Updated LeftPanelHeader to conditionally render 'search' and 'New doc'
buttons based on user authentication status, improving user experience
and access control.
2025-01-15 12:00:40 +01:00
Anthony LC
5bd0764bdd 🌐(frontend) add last translations
Add the missing translations FR / DE.
2025-01-14 17:38:19 +01:00
Samuel Paccoud - DINUM
610948cd16 🐛(backend) fix create document for user when sub does not match
When creating a document on behalf of a user via the server-to-server
API, a special edge case was broken that should should never happen
but happens in our OIDC federation because one of the provider modifies
the users "sub" each time they login.

We end-up with existing users for who the email matches but not the sub.
They were not correctly handled.

I made a few additional fixes and improvements to the endpoint.
2025-01-14 16:18:14 +01:00
Samuel Paccoud - DINUM
96bb99d6ec 🐛(compose) fix "port already taken" errors when starting docker compose
We have changed the project's name from "impress" to "docs" but haven't
replaced all occurrences of impress in the project because we want to be
careful of the consequences on deployments.

The name of the docker compose project was different for the "make pylint"
target. This was causing the bug error on ports. Let's rename it without
waiting.
2025-01-14 16:18:14 +01:00
74 changed files with 1116 additions and 434 deletions

View File

@@ -9,6 +9,16 @@ and this project adheres to
## [Unreleased]
## [2.0.1] - 2025-01-17
## Fixed
-🐛(frontend) share modal is shown when you don't have the abilities #557
-🐛(frontend) title copy break app #564
## [2.0.0] - 2025-01-13
## Added
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
@@ -25,10 +35,15 @@ and this project adheres to
- 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
- 💄(frontend) update DocHeader ui #448
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
## Fixed
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
- 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556
## [1.10.0] - 2024-12-17
@@ -197,7 +212,7 @@ and this project adheres to
- 🛂(frontend) match email if no existing user matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint #232
- 🛂(frontend) redirect to the OIDC when private doc and unauthenticated #292
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
- ♻️(backend) getting list of document versions available for a user #258
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
@@ -326,7 +341,7 @@ and this project adheres to
- ⚡️(e2e) unique login between tests (#80)
- ⚡️(CI) improve e2e job (#86)
- ♻️(frontend) improve the error and message info ui (#93)
- ✏️(frontend) change all occurrences of pad to doc (#99)
- ✏️(frontend) change all occurences of pad to doc (#99)
## Fixed
@@ -346,7 +361,9 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.0.1...main
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2

View File

@@ -7,7 +7,7 @@ UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="impress"
COMPOSE_PROJECT="docs"
# _set_user: set (or unset) default user id used to run docker commands

View File

@@ -6,7 +6,7 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each project (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml

View File

@@ -201,7 +201,7 @@ class DocumentSerializer(ListDocumentSerializer):
"abilities",
"created_at",
"creator",
"is_avorite",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
@@ -264,13 +264,17 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
"""Create the document and associate it with the user or send an invitation."""
language = validated_data.get("language", settings.LANGUAGE_CODE)
# Get the user based on the sub (unique identifier)
# Get the user on its sub (unique identifier). Default on email if allowed in settings
email = validated_data["email"]
try:
user = models.User.objects.get(sub=validated_data["sub"])
except (models.User.DoesNotExist, KeyError):
user = None
email = validated_data["email"]
else:
user = models.User.objects.get_user_by_sub_or_email(
validated_data["sub"], email
)
except models.DuplicateEmailError as err:
raise serializers.ValidationError({"email": [err.message]}) from err
if user:
email = user.email
language = user.language or language
@@ -279,7 +283,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
validated_data["content"]
)
except ConversionError as err:
raise exceptions.APIException(detail="could not convert content") from err
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
) from err
document = models.Document.objects.create(
title=validated_data["title"],
@@ -302,7 +308,11 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
role=models.RoleChoices.OWNER,
)
# Notify the user about the newly created document
self._send_email_notification(document, validated_data, email, language)
return document
def _send_email_notification(self, document, validated_data, email, language):
"""Notify the user about the newly created document."""
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
@@ -313,8 +323,6 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
}
document.send_email(subject, [email], context, language)
return document
def update(self, instance, validated_data):
"""
This serializer does not support updates.

View File

@@ -140,7 +140,6 @@ class UserViewSet(
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
ordering = ["-created_at"]
def get_queryset(self):
"""
@@ -630,7 +629,7 @@ class DocumentViewSet(
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
@@ -677,7 +676,7 @@ class DocumentViewSet(
# Fetch the document and check if the user has access
try:
document, _created = models.Document.objects.get_or_create(pk=pk)
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
@@ -835,7 +834,7 @@ class DocumentAccessViewSet(
serializer_class = serializers.DocumentAccessSerializer
def perform_create(self, serializer):
"""Add new access to the document and email the new added user."""
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
@@ -847,7 +846,7 @@ class DocumentAccessViewSet(
)
def perform_update(self, serializer):
"""Update access to the document and notify the collaboration server."""
"""Update an access to the document and notify the collaboration server."""
access = serializer.save()
access_user_id = None
@@ -860,7 +859,7 @@ class DocumentAccessViewSet(
)
def perform_destroy(self, instance):
"""Delete access to the document and notify the collaboration server."""
"""Delete an access to the document and notify the collaboration server."""
instance.delete()
# Notify collaboration server about the access removed
@@ -1099,7 +1098,7 @@ class InvitationViewset(
return queryset
def perform_create(self, serializer):
"""Save invitation to a document then email the invited user."""
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")

View File

@@ -11,7 +11,7 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import User
from core.models import DuplicateEmailError, User
logger = logging.getLogger(__name__)
@@ -98,7 +98,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"short_name": short_name,
}
user = self.get_existing_user(sub, email)
try:
user = User.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
if user:
if not user.is_active:
@@ -117,16 +120,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch an existing user by sub (or email as a fallback respecting fallback setting."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
return User.objects.filter(email=email).first()
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(

View File

@@ -2,7 +2,7 @@
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
@@ -14,5 +14,5 @@ urlpatterns = [
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozilla_oidc_urls,
*mozzila_oidc_urls,
]

View File

@@ -19,7 +19,6 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.User
skip_postgeneration_save = True
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
@@ -37,8 +36,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
self.save()
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
@@ -48,8 +45,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
self.save()
class DocumentFactory(factory.django.DjangoModelFactory):
"""A factory to create documents"""

View File

@@ -1,5 +1,7 @@
# Generated by Django 5.0.3 on 2024-05-28 20:29
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
import uuid
@@ -143,7 +145,7 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='documentaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
migrations.AddConstraint(
model_name='invitation',
@@ -159,6 +161,6 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='templateaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.1.4 on 2025-01-13 22:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
]
operations = [
migrations.AlterModelOptions(
name='user',
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
]

View File

@@ -1,6 +1,7 @@
"""
Declare and configure the models for the impress core application
"""
# pylint: disable=too-many-lines
import hashlib
import smtplib
@@ -89,6 +90,16 @@ class LinkReachChoices(models.TextChoices):
PUBLIC = "public", _("Public") # Even anonymous users can access the document
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
def __init__(self, message=None, email=None):
"""Set message and email to describe the exception."""
self.message = message
self.email = email
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -126,6 +137,35 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
def get_user_by_sub_or_email(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.get(sub=sub)
except self.model.DoesNotExist as err:
if not email:
return None
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
) from err
return None
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -155,7 +195,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to log in to the admin site
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
@@ -192,14 +232,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = auth_models.UserManager()
objects = UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
class Meta:
db_table = "impress_user"
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
@@ -696,7 +735,7 @@ class DocumentAccess(BaseAccess):
violation_error_message=_("This team is already in this document."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_document_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
@@ -761,7 +800,7 @@ class Template(BaseModel):
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html("{}", body_html), **metadata})
Context({"body": html.format_html(body_html), **metadata})
)
)
css = weasyprint.CSS(
@@ -780,7 +819,7 @@ class Template(BaseModel):
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html("{}", body_html), **metadata})
Context({"body": html.format_html(body_html), **metadata})
)
html_string = f"""
@@ -798,6 +837,7 @@ class Template(BaseModel):
"""
reference_docx = "core/static/reference.docx"
output = BytesIO()
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
@@ -884,7 +924,7 @@ class TemplateAccess(BaseAccess):
violation_error_message=_("This team is already in this template."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_template_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
@@ -939,7 +979,10 @@ class Invitation(BaseModel):
super().clean()
# Check if an identity already exists for the provided email
if User.objects.filter(email=self.email).exists():
if (
User.objects.filter(email=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)

View File

@@ -17,7 +17,7 @@ class CollaborationService:
def reset_connections(self, room, user_id=None):
"""
Reset connections of a room in the collaboration server.
Resetting a connection means that the user will be disconnected and will
Reseting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
"""
endpoint = "reset-connections"

View File

@@ -1,5 +1,6 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from logging import Logger
from unittest import mock
@@ -64,7 +65,33 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub and email didn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
settings, monkeypatch
):
"""
@@ -77,6 +104,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
@@ -93,6 +121,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):

View File

@@ -698,7 +698,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users who are administrators in a document should be allowed to delete access
Users who are administrators in a document should be allowed to delete an access
from the document provided it is not ownership.
"""
user = factories.UserFactory()

View File

@@ -285,7 +285,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
assert response.status_code == 404
# Create a new version should not make it available to the user because
# only the current version is available to the user, but it is excluded
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.save()

View File

@@ -134,7 +134,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI transform
Autenticated who are not related to a document should be able to request AI transform
if the link reach and role permit it.
"""
user = factories.UserFactory()

View File

@@ -154,7 +154,7 @@ def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI translate
Autenticated who are not related to a document should be able to request AI translate
if the link reach and role permit it.
"""
user = factories.UserFactory()

View File

@@ -111,7 +111,7 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Authenticated who are not related to a document should be able to upload a file
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -225,7 +225,7 @@ def test_api_documents_attachment_upload_invalid(client):
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceed the maximum size in settings."""
"""The uploaded file should not exceeed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()

View File

@@ -13,6 +13,7 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User
from core.services.converter_services import ConversionError, YdocConverter
@@ -20,7 +21,7 @@ pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_convert_markdown():
def mock_convert_md():
"""Mock YdocConverter.convert_markdown to return a converted content."""
with patch.object(
YdocConverter,
@@ -169,8 +170,11 @@ def test_api_documents_create_for_owner_invalid_sub():
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_existing(mock_convert_markdown):
"""It should be possible to create a document on behalf of a pre-existing user."""
def test_api_documents_create_for_owner_existing(mock_convert_md):
"""
It should be possible to create a document on behalf of a pre-existing user
by passing their sub and email.
"""
user = factories.UserFactory(language="en-us")
data = {
@@ -189,7 +193,7 @@ def test_api_documents_create_for_owner_existing(mock_convert_markdown):
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -213,10 +217,10 @@ def test_api_documents_create_for_owner_existing(mock_convert_markdown):
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
def test_api_documents_create_for_owner_new_user(mock_convert_md):
"""
It should be possible to create a document on behalf of new users by
passing only their email address.
passing their unknown sub and email address.
"""
data = {
"title": "My Document",
@@ -234,7 +238,7 @@ def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -264,8 +268,190 @@ def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
assert document.creator == user
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback(
mock_convert_md,
):
"""
It should be possible to create a document on behalf of a pre-existing user for
who the sub was not found if the settings allow it. This edge case should not
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
users sub on each login for example...
"""
user = factories.UserFactory(language="en-us")
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator == user
assert document.accesses.filter(user=user, role="owner").exists()
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=False,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_no_fallback(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should raise an error if the email is already used by
a registered user and duplicate emails are not allowed.
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"email": [
(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
]
}
assert mock_convert_md.called is False
assert Document.objects.exists() is False
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 0
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=True,
)
def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplicate(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should be possible to create a new user with the same
email as an existing user if the settings allow it (identification is still done
via the sub in this case).
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator is None
assert document.accesses.exists() is False
invitation = Invitation.objects.get()
assert invitation.email == user.email
assert invitation.role == "owner"
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
# The creator field on the document should be set when the user is created
user = User.objects.create(email=user.email, password="!")
document.refresh_from_db()
assert document.creator == user
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
def test_api_documents_create_for_owner_with_default_language(
mock_send, mock_convert_md
):
"""The default language from settings should apply by default."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
assert mock_send.call_args[0][3] == "de-de"
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown):
def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
"""
Test creating a document with a specific language.
Useful if the remote server knows the user's language.
@@ -287,7 +473,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdo
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -302,7 +488,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdo
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_subject_and_message(
mock_convert_markdown,
mock_convert_md,
):
"""It should be possible to customize the subject and message of the invitation email."""
data = {
@@ -323,7 +509,7 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -336,11 +522,11 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_converter_exception(
mock_convert_markdown,
mock_convert_md,
):
"""It should be possible to customize the subject and message of the invitation email."""
"""In case of converter error, a 400 error should be raised."""
mock_convert_markdown.side_effect = ConversionError("Conversion failed")
mock_convert_md.side_effect = ConversionError("Conversion failed")
data = {
"title": "My Document",
@@ -357,8 +543,33 @@ def test_api_documents_create_for_owner_with_converter_exception(
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}
assert response.status_code == 500
assert response.json() == {"detail": "could not convert content"}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_empty_content():
"""The content should not be empty or a 400 error should be raised."""
data = {
"title": "My Document",
"content": " ",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"content": [
"This field may not be blank.",
],
}

View File

@@ -160,7 +160,7 @@ def test_api_documents_media_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_media_auth_related(via, mock_user_teams):
"""
Users who have specific access to a document, whatever the role, should be able to
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
"""
user = factories.UserFactory()

View File

@@ -647,7 +647,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete access
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()

View File

@@ -84,7 +84,7 @@ def test_models_documents_file_key():
def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role):
"""
Check abilities returned for a document giving insufficient roles to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -121,7 +121,7 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
def test_models_documents_get_abilities_reader(is_authenticated, reach):
"""
Check abilities returned for a document giving reader role to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="reader")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -158,7 +158,7 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
def test_models_documents_get_abilities_editor(is_authenticated, reach):
"""
Check abilities returned for a document giving editor role to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -449,7 +449,7 @@ def test_models_documents__email_invitation__success():
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in French.
The email invitation is sent successfully in french.
"""
document = factories.DocumentFactory()

View File

@@ -27,7 +27,7 @@ def test_models_users_id_unique():
def test_models_users_send_mail_main_existing():
"""The 'email_user' method should send mail to the user's email address."""
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
@@ -37,7 +37,7 @@ def test_models_users_send_mail_main_existing():
def test_models_users_send_mail_main_missing():
"""The 'email_user' method should fail if the user has no email address."""
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:

View File

@@ -1,5 +1,5 @@
"""
Test AI API endpoints in the impress core app.
Test ai API endpoints in the impress core app.
"""
import json

View File

@@ -0,0 +1,30 @@
"""
Unit tests for the User model
"""
import pytest
from impress.settings import Base
def test_invalid_settings_oidc_email_configuration():
"""
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings
should not be both set to True simultaneously.
"""
class TestSettings(Base):
"""Fake test settings."""
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
OIDC_ALLOW_DUPLICATE_EMAILS = True
# The validation is performed during post_setup
with pytest.raises(ValueError) as excinfo:
TestSettings().post_setup()
# Check the exception message
assert str(excinfo.value) == (
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)

View File

@@ -15,7 +15,7 @@ class Command(BaseCommand):
"""Define required arguments "email" and "password"."""
parser.add_argument(
"--email",
help="Email for the user.",
help=("Email for the user."),
)
parser.add_argument(
"--password",

View File

@@ -474,6 +474,15 @@ class Base(Configuration):
environ_prefix=None,
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
# email is activated as fallback for identification (see previous setting).
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
default=False,
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
environ_prefix=None,
)
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
)
@@ -622,9 +631,17 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValueError(
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
class Build(Base):

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"PO-Revision-Date: 2025-01-14 15:14\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -31,23 +31,23 @@ msgstr "Wichtige Daten"
#: core/api/filters.py:16
msgid "Creator is me"
msgstr ""
msgstr "Ersteller bin ich"
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
msgstr "Favorit"
#: core/api/filters.py:22
msgid "Title"
msgstr ""
msgstr "Titel"
#: core/api/serializers.py:307
msgid "A new document was created on your behalf!"
msgstr ""
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: core/api/serializers.py:311
msgid "You have been granted ownership of a new document:"
msgstr ""
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: core/api/serializers.py:414
msgid "Body"
@@ -63,15 +63,15 @@ msgstr "Format"
#: core/authentication/backends.py:57
msgid "Invalid response format or token verification failed"
msgstr ""
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
msgstr "Benutzerinfo enthielt keine erkennbare Benutzeridentifikation"
#: core/authentication/backends.py:88
msgid "User account is disabled"
msgstr ""
msgstr "Benutzerkonto ist deaktiviert"
#: core/models.py:62 core/models.py:69
msgid "Reader"
@@ -127,31 +127,31 @@ msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:135
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: core/models.py:141
msgid "sub"
msgstr ""
msgstr "unter"
#: core/models.py:143
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
#: core/models.py:152
msgid "full name"
msgstr ""
msgstr "Name"
#: core/models.py:153
msgid "short name"
msgstr ""
msgstr "Kurzbezeichnung"
#: core/models.py:155
msgid "identity email address"
msgstr ""
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:160
msgid "admin email address"
msgstr ""
msgstr "Admin E-Mail-Adresse"
#: core/models.py:167
msgid "language"
@@ -159,35 +159,35 @@ msgstr "Sprache"
#: core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: core/models.py:177
msgid "device"
msgstr ""
msgstr "Gerät"
#: core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: core/models.py:182
msgid "staff status"
msgstr ""
msgstr "Status des Teammitgliedes"
#: core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: core/models.py:187
msgid "active"
msgstr ""
msgstr "aktiviert"
#: core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: core/models.py:202
msgid "user"
@@ -216,25 +216,25 @@ msgstr "Unbenanntes Dokument"
#: core/models.py:593
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: core/models.py:597
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: core/models.py:600
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: core/models.py:623
msgid "Document/user link trace"
msgstr ""
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:624
msgid "Document/user link traces"
msgstr ""
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:630
msgid "A link trace already exists for this document/user."
@@ -242,23 +242,23 @@ msgstr ""
#: core/models.py:653
msgid "Document favorite"
msgstr ""
msgstr "Dokumentenfavorit"
#: core/models.py:654
msgid "Document favorites"
msgstr ""
msgstr "Dokumentfavoriten"
#: core/models.py:660
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: core/models.py:682
msgid "Document/user relation"
msgstr ""
msgstr "Dokument/Benutzerbeziehung"
#: core/models.py:683
msgid "Document/user relations"
msgstr ""
msgstr "Dokument/Benutzerbeziehungen"
#: core/models.py:689
msgid "This user is already in this document."
@@ -294,101 +294,101 @@ msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: core/models.py:731
msgid "Template"
msgstr ""
msgstr "Vorlage"
#: core/models.py:732
msgid "Templates"
msgstr ""
msgstr "Vorlagen"
#: core/models.py:871
msgid "Template/user relation"
msgstr ""
msgstr "Vorlage/Benutzer-Beziehung"
#: core/models.py:872
msgid "Template/user relations"
msgstr ""
msgstr "Vorlage/Benutzerbeziehungen"
#: core/models.py:878
msgid "This user is already in this template."
msgstr ""
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: core/models.py:884
msgid "This team is already in this template."
msgstr ""
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:907
msgid "email address"
msgstr ""
msgstr "E-Mail-Adresse"
#: core/models.py:926
msgid "Document invitation"
msgstr ""
msgstr "Einladung zum Dokument"
#: core/models.py:927
msgid "Document invitations"
msgstr ""
msgstr "Dokumenteinladungen"
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr ""
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
msgstr "Unternehmens-Logo"
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
msgstr "Guten Tag %(name)s!"
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
msgstr "Hallo"
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
msgstr "Vielen Dank für Ihren Besuch!"
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
msgstr "Diese E-Mail wurde an %(email)s von <a href=\"%(href)s\">%(name)s</a> gesendet"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
msgstr " Erstellt von %(brandname)s "
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
msgstr "Diese E-Mail wurde an %(email)s von %(name)s [%(href)s ] gesendet"
#: impress/settings.py:236
msgid "English"
msgstr ""
msgstr "Englisch"
#: impress/settings.py:237
msgid "French"
msgstr ""
msgstr "Französisch"
#: impress/settings.py:238
msgid "German"
msgstr ""
msgstr "Deutsch"

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.10.0"
version = "2.0.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -123,7 +123,7 @@ test.describe('Doc Editor', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
// When the visibility is changed, the ws should close the connection (backend signal)
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();

View File

@@ -47,6 +47,7 @@ test.describe('Doc Header', () => {
versions_list: true,
versions_retrieve: true,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
@@ -394,7 +395,31 @@ test.describe('Doc Header', () => {
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
expect(clipboardContent.trim()).toBe(
`<h1 data-level=\"1\">Hello World</h1><p></p>`,
);
});
test('it checks the copy link button', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
accesses_manage: false,
accesses_view: false,
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
});
});
@@ -405,6 +430,45 @@ test.describe('Documents Header mobile', () => {
await page.goto('/');
});
test('it checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
await mockedDocument(page, {
abilities: {
destroy: false,
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
accesses_manage: false,
accesses_view: false,
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
const origin = await page.evaluate(() => window.location.origin);
expect(clipboardContent.trim()).toMatch(
`${origin}/docs/mocked-document-id/`,
);
});
test('it checks the close button on Share modal', async ({ page }) => {
await mockedDocument(page, {
abilities: {
@@ -414,6 +478,7 @@ test.describe('Documents Header mobile', () => {
versions_list: true,
versions_retrieve: true,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,

View File

@@ -79,7 +79,7 @@ test.describe('Document create member', () => {
await expect(quickSearchContent.getByText(email).first()).toBeVisible();
// Check user added
await expect(page.getByText('Share with 3 users')).toBeVisible();
await expect(page.getByText('Share with 2 users')).toBeVisible();
await expect(
quickSearchContent.getByText(users[0].full_name).first(),
).toBeVisible();
@@ -160,7 +160,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
name: 'Saisie de recherche rapide',
});
const email = randomName('test@test.fr', browserName, 1)[0];

View File

@@ -67,7 +67,7 @@ test.describe('Doc Visibility', () => {
test.describe('Doc Visibility: Restricted', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when not authenticated.', async ({
test('A doc is not accessible when not authentified.', async ({
page,
browserName,
}) => {
@@ -98,7 +98,7 @@ test.describe('Doc Visibility: Restricted', () => {
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authenticated but not member.', async ({
test('A doc is not accessible when authentified but not member.', async ({
page,
browserName,
}) => {
@@ -232,6 +232,9 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
const urlDoc = page.url();
await page
@@ -245,6 +248,8 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeHidden();
await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
@@ -316,7 +321,7 @@ test.describe('Doc Visibility: Public', () => {
test.describe('Doc Visibility: Authenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when unauthenticated.', async ({
test('A doc is not accessible when unauthentified.', async ({
page,
browserName,
}) => {
@@ -325,7 +330,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const [docTitle] = await createDoc(
page,
'Authenticated unauthenticated',
'Authenticated unauthentified',
browserName,
1,
);
@@ -408,14 +413,8 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(selectVisibility).toBeHidden();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeHidden();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -469,13 +468,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await expect(selectVisibility).toBeHidden();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeHidden();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.10.0",
"version": "2.0.1",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -13,12 +13,12 @@
},
"devDependencies": {
"@playwright/test": "1.49.1",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
"typescript": "*",
"luxon": "3.5.0",
"@types/luxon": "3.4.2"
"typescript": "*"
},
"dependencies": {
"convert-stream": "1.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.10.0",
"version": "2.0.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -15,9 +15,9 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@blocknote/core": "0.22.0",
"@blocknote/mantine": "0.22.0",
"@blocknote/react": "0.22.0",
"@blocknote/core": "0.21.0",
"@blocknote/mantine": "0.21.0",
"@blocknote/react": "0.21.0",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.0",
"@openfun/cunningham-react": "2.9.4",

View File

@@ -29,7 +29,7 @@ describe('fetchAPI', () => {
});
});
it('check the versioning', () => {
it('check the versionning', () => {
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
void fetchAPI('some/url', {}, '2.0');

View File

@@ -33,7 +33,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)));
}, [asPath]);
// We force to log in except on allowed paths
// We force to login except on allowed paths
useEffect(() => {
if (!initiated || authenticated || pathAllowed) {
return;

View File

@@ -46,8 +46,8 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
terminateCrispSession();
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page, and we are not authenticated
// we store the path in the local storage to redirect to it after log in
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
setAuthUrl() {
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);

View File

@@ -6,6 +6,7 @@ import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
@@ -20,17 +21,19 @@ import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
const cssEditor = (readonly: boolean) => `
&, & > .bn-container, & .ProseMirror {
height:100%;
.bn-side-menu[data-block-type=heading][data-level="1"] {
height: 50px;
}
.bn-side-menu[data-block-type=heading][data-level="2"] {
height: 43px;
}
.bn-side-menu[data-block-type=heading][data-level="3"] {
const cssEditor = (readonly: boolean) => css`
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
h1 {
@@ -52,11 +55,11 @@ const cssEditor = (readonly: boolean) => `
border-left: none;
}
}
.bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
@@ -67,25 +70,25 @@ const cssEditor = (readonly: boolean) => `
&:has(h3) {
padding-top: 16px;
}
};
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
};
.bn-side-menu[data-block-type=heading][data-level="1"] {
height: 46px;
}
.bn-side-menu[data-block-type=heading][data-level="2"] {
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 40px;
}
.bn-side-menu[data-block-type=heading][data-level="3"] {
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 40px;
}
& .bn-editor h1 {
@@ -97,7 +100,7 @@ const cssEditor = (readonly: boolean) => `
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type="paragraph"]
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
@@ -176,7 +179,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)}>
<Box
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly)}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors

View File

@@ -10,7 +10,7 @@ import { toBase64 } from '../utils';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
});
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),

View File

@@ -64,7 +64,12 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
</Text>
</Box>
)}
<Box $direction="row" $align="center" $width="100%">
<Box
$direction="row"
$align="center"
$width="100%"
$padding={{ bottom: 'xs' }}
>
<Box
$direction="row"
$justify="space-between"
@@ -98,7 +103,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<DocToolBox doc={doc} />
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<HorizontalSeparator $withPadding={false} />
</Box>
</>
);

View File

@@ -62,7 +62,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(data) {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);

View File

@@ -16,10 +16,13 @@ import {
Icon,
IconOptions,
} from '@/components';
import { useAuthStore } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import {
Doc,
ModalRemoveDoc,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -37,6 +40,9 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient();
const copyDocLink = useCopyDocLink(doc.id);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
@@ -48,18 +54,24 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const modalShare = useModal();
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { authenticated } = useAuthStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const canViewAccesses = doc.abilities.accesses_view;
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'upload',
label: canViewAccesses ? t('Share') : t('Copy link'),
icon: canViewAccesses ? 'group' : 'link',
callback: () => {
modalShare.open();
if (canViewAccesses) {
modalShare.open();
return;
}
copyDocLink();
},
},
{
@@ -153,7 +165,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$margin={{ left: 'auto' }}
$gap={spacings['2xs']}
>
{authenticated && !isSmallMobile && (
{canViewAccesses && !isSmallMobile && (
<>
{!hasAccesses && (
<Button
@@ -187,12 +199,23 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}}
size={isSmallMobile ? 'small' : 'medium'}
>
{doc.nb_accesses}
{doc.nb_accesses - 1}
</Button>
</Box>
)}
</>
)}
{!canViewAccesses && !isSmallMobile && (
<Button
color="tertiary-text"
onClick={() => {
copyDocLink();
}}
size={isSmallMobile ? 'small' : 'medium'}
>
{t('Copy link')}
</Button>
)}
{!isSmallMobile && (
<Button
color="tertiary-text"

View File

@@ -94,7 +94,7 @@ const convertToImg = (html: string) => {
const doc = parser.parseFromString(html, 'text/html');
const divs = doc.querySelectorAll('div[data-content-type="image"]');
// Loop through each div and replace it with an img
// Loop through each div and replace it with a img
divs.forEach((div) => {
const img = document.createElement('img');

View File

@@ -20,18 +20,18 @@ export const createFavoriteDoc = async ({ id }: CreateFavoriteDocParams) => {
interface CreateFavoriteDocProps {
onSuccess?: () => void;
listInvalidQueries?: string[];
listInvalideQueries?: string[];
}
export function useCreateFavoriteDoc({
onSuccess,
listInvalidQueries,
listInvalideQueries,
}: CreateFavoriteDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, CreateFavoriteDocParams>({
mutationFn: createFavoriteDoc,
onSuccess: () => {
listInvalidQueries?.forEach((queryKey) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -20,18 +20,18 @@ export const deleteFavoriteDoc = async ({ id }: DeleteFavoriteDocParams) => {
interface DeleteFavoriteDocProps {
onSuccess?: () => void;
listInvalidQueries?: string[];
listInvalideQueries?: string[];
}
export function useDeleteFavoriteDoc({
onSuccess,
listInvalidQueries,
listInvalideQueries,
}: DeleteFavoriteDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteFavoriteDocParams>({
mutationFn: deleteFavoriteDoc,
onSuccess: () => {
listInvalidQueries?.forEach((queryKey) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -26,18 +26,18 @@ export const updateDoc = async ({
interface UpdateDocProps {
onSuccess?: (data: Doc) => void;
listInvalidQueries?: string[];
listInvalideQueries?: string[];
}
export function useUpdateDoc({
onSuccess,
listInvalidQueries,
listInvalideQueries,
}: UpdateDocProps = {}) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocParams>({
mutationFn: updateDoc,
onSuccess: (data) => {
listInvalidQueries?.forEach((queryKey) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -30,12 +30,12 @@ export const updateDocLink = async ({
interface UpdateDocLinkProps {
onSuccess?: (data: Doc) => void;
listInvalidQueries?: string[];
listInvalideQueries?: string[];
}
export function useUpdateDocLink({
onSuccess,
listInvalidQueries,
listInvalideQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
@@ -43,7 +43,7 @@ export function useUpdateDocLink({
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data, variable) => {
listInvalidQueries?.forEach((queryKey) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -1,2 +1,3 @@
export * from './useCollaboration';
export * from './useTrans';
export * from './useCopyDocLink';

View File

@@ -0,0 +1,19 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useClipboard } from '@/hook';
import { Doc } from '../types';
export const useCopyDocLink = (docId: Doc['id']) => {
const { t } = useTranslation();
const copyToClipboard = useClipboard();
return useCallback(() => {
copyToClipboard(
`${window.location.origin}/docs/${docId}/`,
t('Link Copied !'),
t('Failed to copy link'),
);
}, [copyToClipboard, docId, t]);
};

View File

@@ -56,8 +56,9 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
const [userQuery, setUserQuery] = useState('');
const [inputValue, setInputValue] = useState('');
const [listHeight, setListHeight] = useState<string>('400px');
const [listHeight, setListHeight] = useState<string>('0px');
const canShare = doc.abilities.accesses_manage;
const canViewAccesses = doc.abilities.accesses_view;
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
const showFooter = selectedUsers.length === 0 && !inputValue;
@@ -94,7 +95,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
count === 1
? t('Document owner')
: t('Share with {{count}} users', {
count: count,
count: count - 1,
}),
elements: members,
endActions: membersQuery.hasNextPage
@@ -137,7 +138,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
};
return {
groupName: t('Search user result', { count: users.length }),
groupName: t('Search user result'),
elements: users,
endActions:
isEmail && users.length === 0
@@ -168,6 +169,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
};
const handleRef = (node: HTMLDivElement) => {
if (!canViewAccesses) {
setListHeight('0px');
return;
}
const inputHeight = canShare ? 70 : 0;
const marginTop = 11;
const footerHeight = node?.clientHeight ?? 0;
@@ -191,7 +196,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
<ShareModalStyle />
<Box
aria-label={t('Share modal')}
$height={modalContentHeight}
$height={canViewAccesses ? modalContentHeight : 'auto'}
$overflow="hidden"
$justify="space-between"
>
@@ -235,39 +240,43 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')}
>
{!showMemberSection && inputValue !== '' && (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)}
/>
)}
{showMemberSection && (
{canViewAccesses && (
<>
{invitationsData.elements.length > 0 && (
<Box aria-label={t('List invitation card')}>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
)}
/>
</Box>
)}
<Box aria-label={t('List members card')}>
{!showMemberSection && inputValue !== '' && (
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)}
/>
</Box>
)}
{showMemberSection && (
<>
{invitationsData.elements.length > 0 && (
<Box aria-label={t('List invitation card')}>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
)}
/>
</Box>
)}
<Box aria-label={t('List members card')}>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
</>
)}
</>
)}
</QuickSearch>

View File

@@ -1,13 +1,9 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, HorizontalSeparator } from '@/components';
import { Doc } from '@/features/docs';
import { Doc, useCopyDocLink } from '@/features/docs';
import { DocVisibility } from './DocVisibility';
@@ -18,7 +14,8 @@ type Props = {
export const DocShareModalFooter = ({ doc, onClose }: Props) => {
const canShare = doc.abilities.accesses_manage;
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const { t } = useTranslation();
return (
<Box
@@ -41,18 +38,7 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
<Button
fullWidth={false}
onClick={() => {
navigator.clipboard
.writeText(window.location.href)
.then(() => {
toast(t('Link Copied !'), VariantType.SUCCESS, {
duration: 3000,
});
})
.catch(() => {
toast(t('Failed to copy link'), VariantType.ERROR, {
duration: 3000,
});
});
copyDocLink();
}}
color="tertiary"
icon={<span className="material-icons">add_link</span>}

View File

@@ -49,7 +49,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
},
);
},
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const updateReach = (link_reach: LinkReach) => {

View File

@@ -29,10 +29,10 @@ export const useTranslatedShareSettings = () => {
icon: 'corporate_fare',
value: LinkReach.AUTHENTICATED,
descriptionReadOnly: t(
'Anyone with the link can see the document provided they are logged in',
'Anyone with the link can view the document if they are logged in',
),
descriptionEdit: t(
'Anyone with the link can edit provided they are logged in',
'Anyone with the link can edit the document if they are logged in',
),
},
[LinkReach.PUBLIC]: {

View File

@@ -8,7 +8,7 @@ import { useResponsiveStore } from '@/stores';
const leftPaddingMap: { [key: number]: string } = {
3: '1.5rem',
2: '0.9rem',
1: '0.3',
1: '0.3rem',
};
export type HeadingsHighlight = {
@@ -44,7 +44,7 @@ export const Heading = ({
onMouseOver={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
onClick={() => {
// With mobile the focus open the keyboard and the scroll are not working
// With mobile the focus open the keyboard and the scroll is not working
if (!isMobile) {
editor.focus();
}

View File

@@ -42,7 +42,7 @@ export const ModalConfirmationVersion = ({
const { push } = useRouter();
const { provider } = useProviderStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
const onDisplaySuccess = () => {
toast(t('Version restored successfully'), VariantType.SUCCESS);

View File

@@ -89,7 +89,7 @@ export const DocsGrid = ({
{title}
</Text>
{!hasDocs && (
{!hasDocs && !loading && (
<Box $padding={{ vertical: 'sm' }} $align="center" $justify="center">
<Text $size="sm" $variation="600" $weight="700">
{t('No documents found')}

View File

@@ -6,6 +6,7 @@ import {
Doc,
KEY_LIST_DOC,
ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
} from '@/features/docs/doc-management';
@@ -20,13 +21,18 @@ export const DocsGridActions = ({
openShareModal,
}: DocsGridActionsProps) => {
const { t } = useTranslation();
const copyDocLink = useCopyDocLink(doc.id);
const canViewAccesses = doc.abilities.accesses_view;
const deleteModal = useModal();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC],
listInvalideQueries: [KEY_LIST_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC],
listInvalideQueries: [KEY_LIST_DOC],
});
const options: DropdownMenuOption[] = [
@@ -43,9 +49,16 @@ export const DocsGridActions = ({
testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: t('Share'),
icon: 'group',
callback: () => openShareModal?.(),
label: canViewAccesses ? t('Share') : t('Copy link'),
icon: canViewAccesses ? 'group' : 'link',
callback: () => {
if (canViewAccesses) {
openShareModal?.();
return;
}
copyDocLink();
},
testId: `docs-grid-actions-share-${doc.id}`,
},

View File

@@ -1,13 +1,14 @@
import { Button, useModal } from '@openfun/cunningham-react';
import { useModal } from '@openfun/cunningham-react';
import { DateTime } from 'luxon';
import { css } from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components';
import { Doc, LinkReach } from '@/features/docs/doc-management';
import { Box, StyledLink, Text } from '@/components';
import { Doc } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import { useResponsiveStore } from '@/stores';
import { DocsGridActions } from './DocsGridActions';
import { DocsGridItemSharedButton } from './DocsGridItemSharedButton';
import { SimpleDocItem } from './SimpleDocItem';
type DocsGridItemProps = {
@@ -17,11 +18,6 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
const { isDesktop } = useResponsiveStore();
const shareModal = useModal();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.nb_accesses - 1;
const isShared = sharedCount > 0;
const handleShareClick = () => {
shareModal.open();
@@ -70,49 +66,13 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$justify="flex-end"
$gap="32px"
>
{isDesktop && isPublic && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShareClick();
}}
size="nano"
fullWidth
icon={<Icon $variation="000" iconName="public" />}
>
{isShared ? sharedCount : undefined}
</Button>
)}
{isDesktop && !isPublic && isRestricted && isShared && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShareClick();
}}
fullWidth
color="tertiary"
size="nano"
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
>
{sharedCount}
</Button>
)}
{isDesktop && !isPublic && isAuthenticated && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShareClick();
}}
fullWidth
size="nano"
icon={<Icon $variation="000" iconName="corporate_fare" />}
>
{sharedCount}
</Button>
{isDesktop && (
<DocsGridItemSharedButton
doc={doc}
handleClick={handleShareClick}
/>
)}
<DocsGridActions doc={doc} openShareModal={handleShareClick} />
</Box>
</Box>

View File

@@ -0,0 +1,66 @@
import { Button } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { Box, Icon } from '@/components';
import { Doc, LinkReach } from '../../doc-management';
type Props = {
doc: Doc;
handleClick: () => void;
};
export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.nb_accesses - 1;
const isShared = sharedCount > 0;
const icon = useMemo(() => {
if (isPublic) {
return 'public';
}
if (isAuthenticated) {
return 'corporate_fare';
}
if (isRestricted) {
return 'group';
}
return undefined;
}, [isPublic, isAuthenticated, isRestricted]);
if (!icon) {
return null;
}
if (!doc.abilities.accesses_view) {
return (
<Box $align="center" $width="100%">
<Icon $variation="800" $theme="primary" iconName={icon} />
</Box>
);
}
return (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleClick();
}}
fullWidth
color={isRestricted ? 'tertiary' : 'primary'}
size="nano"
icon={
<Icon
$variation={isRestricted ? '800' : '000'}
$theme={isRestricted ? 'primary' : 'greyscale'}
iconName={icon}
/>
}
>
{isShared ? sharedCount : undefined}
</Button>
);
};

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
import { PropsWithChildren } from 'react';
import { Box, Icon, SeparatedSection } from '@/components';
import { useAuthStore } from '@/core';
import { useCreateDoc } from '@/features/docs/doc-management';
import { DocSearchModal } from '@/features/docs/doc-search';
import { useCmdK } from '@/hook/useCmdK';
@@ -13,6 +14,7 @@ import { useLeftPanelStore } from '../stores';
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const router = useRouter();
const searchModal = useModal();
const auth = useAuthStore();
useCmdK(searchModal.open);
const { togglePanel } = useLeftPanelStore();
@@ -52,16 +54,20 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
<Icon $variation="800" $theme="primary" iconName="house" />
}
/>
<Button
onClick={searchModal.open}
size="medium"
color="tertiary-text"
icon={
<Icon $variation="800" $theme="primary" iconName="search" />
}
/>
{auth.authenticated && (
<Button
onClick={searchModal.open}
size="medium"
color="tertiary-text"
icon={
<Icon $variation="800" $theme="primary" iconName="search" />
}
/>
)}
</Box>
<Button onClick={createNewDoc}>{t('New doc')}</Button>
{auth.authenticated && (
<Button onClick={createNewDoc}>{t('New doc')}</Button>
)}
</Box>
</SeparatedSection>
{children}

View File

@@ -112,7 +112,7 @@ export class ApiPlugin implements WorkboxPlugin {
};
/**
* When we get a network error.
* When we get an network error.
*/
handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => {
if (!this.isFetchDidFailed) {

View File

@@ -34,7 +34,7 @@ interface IDocsDB extends DBSchema {
type TableName = 'doc-list' | 'doc-item' | 'doc-mutation';
/**
* IndexDB version must be an integer
* IndexDB version must be a integer
* @returns
*/
const getCurrentVersion = () => {

View File

@@ -1 +1,2 @@
export * from './useDate';
export * from './useClipboard';

View File

@@ -0,0 +1,34 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useClipboard = () => {
const { toast } = useToastProvider();
const { t } = useTranslation();
return useCallback(
(text: string, successMessage?: string, errorMessage?: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
toast(
successMessage ?? t('Copied to clipboard'),
VariantType.SUCCESS,
{
duration: 3000,
},
);
})
.catch(() => {
toast(
errorMessage ?? t('Failed to copy to clipboard'),
VariantType.ERROR,
{
duration: 3000,
},
);
});
},
[t, toast],
);
};

View File

@@ -2,85 +2,145 @@
"de": {
"translation": {
"\"{{email}}\" is already invited to the document.": "\"{{email}}\" ist bereits zum Dokument eingeladen.",
"\"{{email}}\" is already member of the document.": "\"{{email}}\" ist bereits Mitglied des Dokuments.",
"AI Actions": "KI-Aktionen",
"AI seems busy! Please try again.": "KI scheint beschäftigt! Bitte versuchen Sie es erneut.",
"Accessibility": "Barrierefreiheit",
"Accessibility statement": "Erklärung zur Barrierefreiheit",
"Add": "Hinzufügen",
"Address:": "Anschrift:",
"Administrator": "Administrator",
"Anyone on the internet with the link can view": "Für jeden im Internet mit diesem Link sichtbar",
"All docs": "Alle Dokumente",
"Anonymous": "Gast",
"Anyone with the link can edit the document": "Jeder mit dem Link kann das Dokument bearbeiten",
"Anyone with the link can edit the document if they are logged in": "Jeder mit dem Link kann das Dokument bearbeiten, wenn er angemeldet ist",
"Anyone with the link can see the document": "Jeder mit dem Link kann das Dokument ansehen",
"Anyone with the link can view the document if they are logged in": "Jeder mit dem Link kann das Dokument ansehen, wenn er angemeldet ist",
"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",
"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",
"Compliance status": "Konformitätsstatus",
"Confirm deletion": "Löschung bestätigen",
"Connected": "Verbunden",
"Content modal to delete document": "Inhalts-Modal zum Löschen des Dokuments",
"Content modal to export the document": "Inhalte zum Exportieren des Dokuments",
"Convert Markdown": "Markdown konvertieren",
"Copied to clipboard": "In die Zwischenablage kopiert",
"Copy as {{format}}": "Als {{format}} kopieren",
"Copy link": "Link kopieren",
"Correct": "Korrigieren",
"Delete": "Löschen",
"Delete a doc": "Dokument löschen",
"Delete document": "Dokument löschen",
"Deleting the document \"{{title}}\"": "Lösche das Dokument \"{{title}}\"",
"Doc visibility card": "Dokumenten-Sichtbarkeitskarte",
"Docs": "Docs",
"Docs Logo": "Docs Logo",
"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 owner": "Besitzer des Dokuments",
"Document title updated successfully": "Titel des Dokuments erfolgreich aktualisiert",
"Download": "Herunterladen",
"E-mail:": "E-Mail:",
"Edition": "Bearbeiten",
"Editor": "Editor",
"Editor unavailable": "Editor nicht verfügbar",
"Error during delete invitation": "Fehler beim Löschen der Einladung",
"Error during invitation update": "Fehler beim Aktualisieren der Einladung",
"Error during update invitation": "Fehler beim Aktualisieren der Einladung",
"Error while deleting invitation": "Fehler beim Löschen der Einladung",
"Export": "Exportieren",
"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 copy to clipboard": "Fehler beim Kopieren in die Zwischenablage",
"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",
"Format": "Format",
"History": "Versionsverlauf",
"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",
"Invite": "Einladen",
"It is the card information about the document.": "Es handelt sich um die Karteninformationen zum Dokument.",
"It is the document title": "Es ist der Titel des Dokuments",
"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",
"Last update: {{update}}": "Zuletzt aktualisiert: {{update}}",
"Legal Notice": "Impressum",
"Legal notice": "Impressum",
"Link Copied !": "Link kopiert!",
"Link parameters": "Link-Parameter",
"List invitation card": "Einladungsliste anzeigen",
"List members card": "Mitgliederliste anzeigen",
"Load more": "Mehr anzeigen",
"Login": "Anmelden",
"Logout": "Abmelden",
"Modal confirmation to restore the version": "Modale Bestätigung um die Version wiederherzustellen",
"More docs": "Weitere Dokumente",
"My docs": "Meine Dokumente",
"Name": "Name",
"New doc": "Neues Dokument",
"No active search": "Keine aktive Suche",
"No document found": "Kein Dokument gefunden",
"No documents found": "Keine Dokumente gefunden",
"No editor found": "Kein Editor gefunden",
"No versions": "Keine Versionen",
"OK": "OK",
"Offline ?!": "Offline?!",
"Only for people with access": "Nur für Personen mit Zugriff",
"Only invited people can access": "Nur eingeladene Personen haben Zugriff",
"Open the document options": "Öffnen Sie die Dokumentoptionen",
"Open the header menu": "Öffne das Kopfzeilen-Menü",
"Ouch !": "Autsch!",
"Owner": "Besitzer",
"PDF": "PDF",
"Pending invitations": "Ausstehende Einladungen",
"Personal data and cookies": "Personenbezogene Daten und Cookies",
"Pin": "Anheften",
"Pinned documents": "Angepinnte Dokumente",
"Private": "Privat",
"Public": "Öffentlich",
"Read only, you cannot edit this document.": "Nur lesen: Sie können dieses Dokument nicht bearbeiten.",
"Public document": "Öffentliches Dokument",
"Quick search input": "Schnellsuche-Eingabe",
"Reader": "Leser",
"Reading": "Lesen",
"Remove": "Löschen",
"Rename": "Umbenennen",
"Rephrase": "Umformulieren",
"Restore": "Wiederherstellen",
"Role": "Rolle",
"Search by email": "Nach E-Mail suchen",
"Search": "Suchen",
"Search modal": "Suche Modal",
"Search user result": "Suchergebnis",
"Select a document": "Dokument auswählen",
"Select a version on the right to restore": "Wählen Sie rechts eine Version zum Wiederherstellen aus",
"Share": "Teilen",
"Share modal": "Teilen-Modal",
"Share the document": "Dokument teilen",
"Share with {{count}} users_many": "Teilen mit {{count}} Benutzern",
"Share with {{count}} users_one": "Teilen mit {{count}} Benutzern",
"Share with {{count}} users_other": "Teilen mit {{count}} Benutzern",
"Shared with me": "Mit mir geteilt",
"Something bad happens, please retry.": "Etwas ist schiefgelaufen, bitte versuchen Sie es erneut.",
"Table of contents": "Inhaltsverzeichnis",
"Summarize": "Zusammenfassen",
"Summary": "Zusammenfassung",
"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.",
"The document visibility has been updated.": "Die Sichtbarkeit des Dokuments 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?": "",
"Too many requests. Please wait 60 seconds.": "Zu viele Anfragen. Bitte warten Sie 60 Sekunden.",
"Type a name or email": "Geben Sie einen Namen oder eine E-Mail-Adresse ein",
"Type the name of a document": "Geben Sie den Namen eines Dokuments ein",
"Unless otherwise stated, all content on this site is under": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
"Unpin": "Lösen",
"Untitled document": "Unbenanntes Dokument",
"Updated at": "Aktualisiert am",
"User {{email}} added to the document.": "Benutzer {{email}} wurde dem Dokument hinzugefügt.",
"Validate": "Bestätigen",
"Upload your docs to a Microsoft Word, Open Office or PDF document.": "Laden Sie Ihre Dokumente zu einem Microsoft Word, Open Office oder PDF Dokument hoch.",
"Use as prompt": "Als Prompt verwenden",
"Version history": "Versionsverlauf",
"Version restored successfully": "Version erfolgreich wiederhergestellt",
"We didn't find a mail matching, try to be more accurate": "Wir haben keine übereinstimmende E-Mail gefunden, versuchen Sie genauer zu sein",
"Visibility": "Sichtbarkeit",
"Visibility mode": "Sichtbarkeitseinstellungen",
"Warning": "Warnung",
"We try to respond within 2 working days.": "Wir versuchen, innerhalb von 2 Arbeitstagen zu antworten.",
"Word / Open Office": "Word / Open Office",
"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.",
"Your current document will revert to this version.": "Ihr aktuelles Dokument wird auf diese Version zurückgesetzt.",
@@ -96,21 +156,23 @@
"AI seems busy! Please try again.": "L'IA semble occupée ! Veuillez réessayer.",
"Accessibility": "Accessibilité",
"Accessibility statement": "Déclaration d'accessibilité",
"Add": "Ajouter",
"Address:": "Adresse :",
"Administrator": "Administrateur",
"All docs": "Tous les documents",
"Anonymous": "Anonyme",
"Anyone on the internet with the link can view": "Les personnes disposant du lien peuvent y accéder",
"Anyone with the link can edit the document": "N'importe qui avec le lien peut éditer le document",
"Anyone with the link can edit the document if they are logged in": "N'importe qui avec le lien peut éditer le document à condition qu'il soit connecté",
"Anyone with the link can see the document": "N'importe qui avec le lien peut voir le document",
"Anyone with the link can view the document if they are logged in": "N'importe qui avec le lien peut voir le document à condition qu'il soit connecté",
"Are you sure you want to delete the document \"{{title}}\"?": "Êtes-vous sûr de vouloir supprimer le document \"{{title}}\" ?",
"Authenticated": "Authentifié",
"Back to home page": "Retour à l'accueil",
"Can read and edit": "Peut lire et éditer",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Choose a role": "Choisissez un rôle",
"Close the modal": "Fermer la modale",
"Compliance status": "État de conformité",
"Confirm deletion": "Confirmer la suppression",
"Connected": "Connecté",
"Content modal to delete document": "Contenu modal pour supprimer le document",
"Content modal to export the document": "Contenu modal pour exporter le document",
"Convert Markdown": "Convertir le Markdown",
@@ -121,35 +183,38 @@
"Copyright": "Copyright",
"Correct": "Corriger",
"Defender of Rights - Free response - 71120 75342 Paris CEDEX 07": "Défenseur des droits - Réponse gratuite - 71120 75342 Paris CEDEX 07",
"Delete": "Supprimer",
"Delete a doc": "Supprimer un doc",
"Delete document": "Supprimer le document",
"Deleting the document \"{{title}}\"": "Suppression du document \"{{title}}\"",
"Doc visibility card": "Carte de visibilité du doc",
"Docs": "Docs",
"Docs Logo": "Logo Docs",
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs : Votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement et en toute sécurité.",
"Document owner": "Propriétaire du document",
"Document title updated successfully": "Titre du document mis à jour avec succès",
"Download": "Télécharger",
"E-mail:": "E-mail:",
"Edition": "Édition",
"Editor": "Éditeur",
"Editor unavailable": "Éditeur indisponible",
"Error during delete invitation": "Erreur lors de la suppression de l'invitation",
"Error during invitation update": "Erreur lors de la mise à jour de l'invitation",
"Error during update invitation": "Erreur lors de la mise à jour de l'invitation",
"Error while deleting invitation": "Erreur lors de la suppression de l'invitation",
"Established on December 20, 2023.": "Établi le 20 décembre 2023.",
"Export": "Exporter",
"Failed to add the member in the document.": "Impossible d'ajouter le membre dans le document.",
"Failed to copy link": "Échec de la copie du lien",
"Failed to copy to clipboard": "Échec de la copie dans le presse-papier",
"Failed to create the invitation for {{email}}.": "Impossible de créer l'invitation pour {{email}}.",
"Find a member to add to the document": "Trouver un membre à ajouter au document",
"Format": "Format",
"French Interministerial Directorate for Digital Affairs (DINUM), 20 avenue de Ségur 75007 Paris.": "Direction interministérielle des affaires numériques (DINUM), 20 avenue de Segur 75007 Paris.",
"History": "Historique",
"How people can interact with the document": "Comment les gens peuvent interagir avec le document",
"If a member is editing, his works can be lost.": "Si un membre est en train d'éditer, ses travaux peuvent être perdus.",
"If you are unable to access a content or a service, you can contact the person responsible for https://lasuite.numerique.gouv.fr to be directed to an accessible alternative or to obtain the content in another form.": "Si vous ne pouvez pas accéder à un contenu ou à un service, vous pouvez contacter la personne responsable de https://lasuite. umerique.gouv.fr pour être dirigé vers une alternative accessible ou pour obtenir le contenu sous une autre forme.",
"Illustration:": "Illustration :",
"Improvement and contact": "Amélioration et contact",
"Invitation sent to {{email}}.": "Invitation envoyée à {{email}}.",
"Invite new members to {{title}}": "Invitez de nouveaux membres à rejoindre {{title}}",
"Invited": "Invité",
"Invite": "Inviter",
"It is the card information about the document.": "Il s'agit de la carte d'information du document.",
"It is the document title": "Il s'agit du titre du document",
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Il semble que la page que vous cherchez n'existe pas ou ne puisse pas être affichée correctement.",
@@ -159,6 +224,7 @@
"Legal Notice": "Mentions Legales",
"Legal notice": "Mention légale",
"Link Copied !": "Lien copié !",
"Link parameters": "Paramètres du lien",
"List invitation card": "Carte de liste d'invitation",
"List members card": "Carte liste des membres",
"Load more": "Afficher plus",
@@ -170,50 +236,57 @@
"My docs": "Mes documents",
"Name": "Nom",
"New doc": "Nouveau doc",
"No active search": "Aucune recherche active",
"No document found": "Aucun document trouvé",
"No documents found": "Aucun document trouvé",
"No editor found": "Pas d'éditeur trouvé",
"No versions": "Aucune version",
"Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.",
"OK": "OK",
"Offline ?!": "Hors-ligne ?!",
"Only for authenticated users": "Uniquement pour les utilisateurs authentifiés",
"Only for people with access": "Seulement pour les personnes avec accès",
"Only invited people can access": "Seules les personnes invitées peuvent accéder",
"Open the document options": "Ouvrir les options du document",
"Open the header menu": "Ouvrir le menu d'en-tête",
"Ouch !": "Aïe !",
"Owner": "Propriétaire",
"PDF": "PDF",
"Pending invitations": "Invitations en attente",
"Personal data and cookies": "Données personnelles et cookies",
"Pin": "Épingler",
"Pinned documents": "Documents épinglés",
"Private": "Privé",
"Public": "Public",
"Public document": "Document public",
"Publication Director": "Directeur de la publication",
"Publisher": "Éditeur",
"Read only": "Lecture seule",
"Read only, you cannot edit this document.": "En lecture seule, vous ne pouvez pas éditer ce document.",
"Quick search input": "Saisie de recherche rapide",
"Reader": "Lecteur",
"Reading": "Lecture seul",
"Remedies": "Voie de recours",
"Remove": "Supprimer",
"Rename": "Renommer",
"Rephrase": "Reformuler",
"Restore": "Restaurer",
"Restricted": "Restreint",
"Role": "Rôle",
"Search by email": "Recherche par email",
"Search": "Rechercher",
"Search modal": "Modale de partage",
"Search user result": "Résultat de la recherche utilisateur",
"Select a document": "Sélectionnez un document",
"Select a version on the right to restore": "Sélectionnez une version à droite à restaurer",
"Send a letter by post (free of charge, no stamp needed):": "Envoyer un courrier par la poste (gratuit, ne pas mettre de timbre):",
"Share": "Partager",
"Share modal": "Modale de partage",
"Share the document": "Partager le document",
"Share with {{count}} users_many": "Partager avec {{count}} utilisateurs",
"Share with {{count}} users_one": "Partager avec {{count}} utilisateur",
"Share with {{count}} users_other": "Partager avec {{count}} utilisateurs",
"Shared with me": "Partagés avec moi",
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
"Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Directrice numérique interministériel (DINUM).",
"Summarize": "Résumer",
"Summary": "Sommaire",
"Table of contents": "Table des matières",
"Template": "Template",
"The document has been deleted.": "Le document a bien été supprimé.",
"The document visibility has been updated.": "La visibilité du document a été mise à jour.",
"The invitation has been removed.": "L'invitation a été supprimée.",
"The member has been removed from the document": "Le membre a été retiré du document",
"The role has been updated": "Le rôle a été mis à jour",
"The role has been updated.": "Le rôle a été mis à jour.",
"The team in charge of the digital workspace \"La Suite numérique\" can be contacted directly at": "L'équipe responsable de l'espace de travail numérique \"La Suite numérique\" peut être contactée directement à l'adresse",
"This accessibility statement applies to the site hosted on": "Cette déclaration d'accessibilité s'applique au site hébergé sur",
"This allows us to measure the number of visits and understand which pages are the most viewed.": "Cela nous permet de mesurer le nombre de visites et de comprendre quelles pages sont les plus consultées.",
@@ -222,18 +295,19 @@
"This site places a small text file (a \"cookie\") on your computer when you visit it.": "Ce site place un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le visitez.",
"This will protect your privacy, but will also prevent the owner from learning from your actions and creating a better experience for you and other users.": "Cela protégera votre vie privée, mais empêchera également le propriétaire d'apprendre de vos actions et de créer une meilleure expérience pour vous et les autres utilisateurs.",
"Too many requests. Please wait 60 seconds.": "Trop de demandes. Veuillez patienter 60 secondes.",
"Type a name or email": "Tapez un nom ou un email",
"Type the name of a document": "Tapez le nom d'un document",
"Unless otherwise stated, all content on this site is under": "Sauf mention contraire, tout le contenu de ce site est sous",
"Unpin": "Désépingler",
"Untitled document": "Document sans titre",
"Updated at": "Mise à jour le",
"Upload your docs to a Microsoft Word, Open Office or PDF document.": "Téléchargez vos documents dans un document Microsoft Word, Open Office ou PDF.",
"Use as prompt": "Utiliser comme un prompt",
"User {{email}} added to the document.": "L'utilisateur {{email}} a été ajouté au document.",
"Validate": "Valider",
"Version history": "Historique des versions",
"Version restored successfully": "Version restaurée avec succès",
"Visibility": "Visibilité",
"Visibility mode": "Mode de visibilité",
"Warning": "Attention",
"We didn't find a mail matching, try to be more accurate": "Nous n'avons pas trouvé de correspondance par mail, essayez d'être plus précis",
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure daudience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
"We try to respond within 2 working days.": "Nous essayons de répondre dans les 2 jours ouvrables.",
"Word / Open Office": "Word / Open Office",

View File

@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "1.10.0",
"version": "2.0.1",
"private": true,
"workspaces": {
"packages": [

View File

@@ -1,6 +1,6 @@
{
"name": "eslint-config-impress",
"version": "1.10.0",
"version": "2.0.1",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."

View File

@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.10.0",
"version": "2.0.1",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",

View File

@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "1.10.0",
"version": "2.0.1",
"description": "Y.js provider for docs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",
@@ -35,8 +35,8 @@
"@types/node": "*",
"@types/supertest": "6.0.2",
"@types/ws": "8.5.13",
"eslint-config-impress": "*",
"cross-env": "7.0.3",
"eslint-config-impress": "*",
"jest": "29.7.0",
"nodemon": "3.1.9",
"supertest": "7.0.0",

View File

@@ -988,7 +988,56 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@blocknote/core@0.22.0", "@blocknote/core@^0.22.0":
"@blocknote/core@0.21.0", "@blocknote/core@^0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.21.0.tgz#b54baaa3eca3b700c80c59113a837c3d4153dca2"
integrity sha512-TQAN0qRCkXpz5AwfdxjuFvIKWsU4bBI5d/e5iX7PEkhwf4PFgPHsMUl2uuGw5o/hhjzoU54YKaAjlJlWyw4goA==
dependencies:
"@emoji-mart/data" "^1.2.1"
"@tiptap/core" "^2.7.1"
"@tiptap/extension-bold" "^2.7.1"
"@tiptap/extension-code" "^2.7.1"
"@tiptap/extension-collaboration" "^2.7.1"
"@tiptap/extension-collaboration-cursor" "^2.7.1"
"@tiptap/extension-gapcursor" "^2.7.1"
"@tiptap/extension-hard-break" "^2.7.1"
"@tiptap/extension-history" "^2.7.1"
"@tiptap/extension-horizontal-rule" "^2.7.1"
"@tiptap/extension-italic" "^2.7.1"
"@tiptap/extension-link" "^2.7.1"
"@tiptap/extension-paragraph" "^2.7.1"
"@tiptap/extension-strike" "^2.7.1"
"@tiptap/extension-table-cell" "^2.7.1"
"@tiptap/extension-table-header" "^2.7.1"
"@tiptap/extension-table-row" "^2.7.1"
"@tiptap/extension-text" "^2.7.1"
"@tiptap/extension-underline" "^2.7.1"
"@tiptap/pm" "^2.7.1"
emoji-mart "^5.6.0"
hast-util-from-dom "^4.2.0"
prosemirror-dropcursor "^1.8.1"
prosemirror-highlight "^0.9.0"
prosemirror-model "^1.23.0"
prosemirror-state "^1.4.3"
prosemirror-tables "^1.6.1"
prosemirror-transform "^1.9.0"
prosemirror-view "^1.33.7"
rehype-format "^5.0.0"
rehype-parse "^8.0.4"
rehype-remark "^9.1.2"
rehype-stringify "^9.0.3"
remark-gfm "^3.0.1"
remark-parse "^10.0.1"
remark-rehype "^10.1.0"
remark-stringify "^10.0.2"
shiki "^1.22.0"
unified "^10.1.2"
uuid "^8.3.2"
y-prosemirror "1.2.13"
y-protocols "^1.0.6"
yjs "^13.6.15"
"@blocknote/core@^0.22.0":
version "0.22.0"
resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.22.0.tgz#2f363f9677d4fa5f20299b22850f5f34a6340a55"
integrity sha512-AAEx01zK6u+b1SsZniMm/aogEMjasF4vA9ZHgFGj04G7AwK5Hjwa0Sxre58qcW+KzuvR09CQHTkwjmgVmJX/HA==
@@ -1037,19 +1086,31 @@
y-protocols "^1.0.6"
yjs "^13.6.15"
"@blocknote/mantine@0.22.0":
version "0.22.0"
resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.22.0.tgz#15509aaefe88c3efd73a884b9fb1e0584a6223ec"
integrity sha512-6irIKCGUpE47X8qWLx9oa5ndztSrvLEHgVRp+fdVUHMJCx0/OzijJyYTTFKw8yEI9qc01pjmwdYMZrMMZybyGw==
"@blocknote/mantine@0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.21.0.tgz#b8a640f498a4884129fe33f854be8d2bb842ea41"
integrity sha512-GAxgvn/87wDyE8qdkystTkEbqE8AFO81gaMJ6df0P6ZAdfIH3sFYUf9MffVOjtq7T6NSCM9vHNnhHsC9K8m/fg==
dependencies:
"@blocknote/core" "^0.22.0"
"@blocknote/react" "^0.22.0"
"@blocknote/core" "^0.21.0"
"@blocknote/react" "^0.21.0"
"@mantine/core" "^7.10.1"
"@mantine/hooks" "^7.10.1"
"@mantine/utils" "^6.0.21"
react-icons "^5.2.1"
"@blocknote/react@0.22.0", "@blocknote/react@^0.22.0":
"@blocknote/react@0.21.0", "@blocknote/react@^0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.21.0.tgz#ad8907f89575e8c139d07d75bdb66ef4e33f84f9"
integrity sha512-eBKe3hihGNeO4G/qKKJ/B5uuEmWm8XMbT8SxJ2zpNTjHx5lLP45vhtjAM+HCzQqz4xYacc2NphUIdjPPH5eXrQ==
dependencies:
"@blocknote/core" "^0.21.0"
"@floating-ui/react" "^0.26.4"
"@tiptap/core" "^2.7.1"
"@tiptap/react" "^2.7.1"
lodash.merge "^4.6.2"
react-icons "^5.2.1"
"@blocknote/react@^0.22.0":
version "0.22.0"
resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.22.0.tgz#a17167a26b70ef421218ae3e49d15cca751291f0"
integrity sha512-Y6Oj99iOKnlh2FE/lgy8kO5PziPnA8MyEJyjCH9Jbvlc9t493L9EFmLK8iKBZek7sh0TOzhXGBOA6lIpk02X6A==
@@ -11080,7 +11141,7 @@ prosemirror-trailing-node@^3.0.0:
"@remirror/core-constants" "3.0.0"
escape-string-regexp "^4.0.0"
prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.7.3:
prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.7.3, prosemirror-transform@^1.9.0:
version "1.10.2"
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz#8ebac4e305b586cd96595aa028118c9191bbf052"
integrity sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==

View File

@@ -93,4 +93,4 @@ releases:
environments:
dev:
values:
- version: 1.10.0
- version: 2.0.1

View File

@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.10.0",
"version": "2.0.1",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {