Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6403be62e | |||
| ca3502ee4d | |||
| 8c5352103a | |||
| 3e3ee7e698 | |||
| af1c40995b | |||
| 1da0f6600e | |||
| a3fdb206ef | |||
| da4d323144 | |||
| 3e45193a7c | |||
| 7a55e31a73 | |||
| 4baef38cae | |||
| 1eba8b77c0 | |||
| 579ff98a5a | |||
| fe34b93249 | |||
| 205960106b | |||
| d685b541c5 | |||
| 834ed4226f | |||
| 3f8e105035 | |||
| 431bec3970 | |||
| 54f2762e79 | |||
| 9c438eba06 | |||
| bedb0573b8 | |||
| 9d3088d9db |
@@ -0,0 +1 @@
|
||||
nodejs 22.21.1
|
||||
@@ -845,6 +845,32 @@
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "encryption",
|
||||
"name": "Encryption",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"publicClient": true,
|
||||
"protocol": "openid-connect",
|
||||
"redirectUris": [
|
||||
"http://localhost:7202/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:7202"
|
||||
],
|
||||
"frontchannelLogout": true,
|
||||
"attributes": {},
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"profile",
|
||||
"roles",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": []
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
|
||||
@@ -51,8 +51,8 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
# OIDC_STORE_ACCESS_TOKEN = True
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint and encryption service.
|
||||
OIDC_STORE_ACCESS_TOKEN = True
|
||||
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
|
||||
@@ -66,10 +66,13 @@ class ListDocumentFilter(DocumentFilter):
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
is_encrypted = django_filters.BooleanFilter(
|
||||
method="filter_is_encrypted", label=_("Encrypted")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["is_creator_me", "is_favorite", "title"]
|
||||
fields = ["is_creator_me", "is_favorite", "is_encrypted", "title"]
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_creator_me(self, queryset, name, value):
|
||||
@@ -110,6 +113,24 @@ class ListDocumentFilter(DocumentFilter):
|
||||
|
||||
return queryset.filter(is_favorite=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_encrypted(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on whether they are encrypted.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_encrypted=true
|
||||
→ Filters documents encrypted
|
||||
- /api/v1.0/documents/?is_encrypted=false
|
||||
→ Filters documents not encrypted
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(is_encrypted=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_masked(self, queryset, name, value):
|
||||
"""
|
||||
|
||||
@@ -29,11 +29,12 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
full_name = serializers.SerializerMethodField(read_only=True)
|
||||
short_name = serializers.SerializerMethodField(read_only=True)
|
||||
suite_user_id = serializers.CharField(source='sub', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email", "full_name", "short_name", "language", "suite_user_id"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name", "suite_user_id"]
|
||||
|
||||
def get_full_name(self, instance):
|
||||
"""Return the full name of the user."""
|
||||
@@ -57,25 +58,33 @@ class UserLightSerializer(UserSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["full_name", "short_name"]
|
||||
read_only_fields = ["full_name", "short_name"]
|
||||
fields = ["id", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "full_name", "short_name"]
|
||||
|
||||
|
||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
is_encrypted = serializers.BooleanField(read_only=True)
|
||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||
user_role = serializers.SerializerMethodField(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
deleted_at = serializers.SerializerMethodField(read_only=True)
|
||||
accesses_user_ids = serializers.SerializerMethodField(read_only=True)
|
||||
accesses_fingerprints_per_user = serializers.SerializerMethodField(read_only=True)
|
||||
encrypted_document_symmetric_key_for_user = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_fingerprints_per_user",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
@@ -84,8 +93,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -99,6 +110,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
@@ -107,8 +119,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -151,6 +165,38 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Return the deleted_at of the current document."""
|
||||
return instance.ancestors_deleted_at
|
||||
|
||||
def get_accesses_user_ids(self, instance):
|
||||
"""Return user IDs of members with access to this document.
|
||||
The frontend uses these to fetch public keys from the encryption service."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
return [str(uid) for uid in instance.accesses_user_ids]
|
||||
|
||||
def get_accesses_fingerprints_per_user(self, instance):
|
||||
"""Return fingerprints of users' public keys at share time."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
if not instance.is_encrypted:
|
||||
return None
|
||||
return instance.accesses_fingerprints_per_user
|
||||
|
||||
def get_encrypted_document_symmetric_key_for_user(self, instance):
|
||||
"""Return the encrypted symmetric key for the current user."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
if not instance.is_encrypted:
|
||||
return None
|
||||
try:
|
||||
access = models.DocumentAccess.objects.get(
|
||||
document=instance, user=request.user
|
||||
)
|
||||
return access.encrypted_document_symmetric_key_for_user
|
||||
except models.DocumentAccess.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class DocumentLightSerializer(serializers.ModelSerializer):
|
||||
"""Minial document serializer for nesting in document accesses."""
|
||||
@@ -165,6 +211,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"""Serialize documents with all fields for display in detail views."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
contentEncrypted = serializers.BooleanField(required=False, write_only=True)
|
||||
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||
file = serializers.FileField(
|
||||
required=False, write_only=True, allow_null=True, max_length=255
|
||||
@@ -175,18 +222,23 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_fingerprints_per_user",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
"computed_link_role",
|
||||
"content",
|
||||
"contentEncrypted",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"file",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -209,7 +261,9 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -228,6 +282,11 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
if request and request.method == "POST":
|
||||
fields["id"].read_only = False
|
||||
|
||||
# if user is not authenticated remove public keys information since he can still retrieve the document
|
||||
if request and not request.user.is_authenticated:
|
||||
fields.pop("accesses_user_ids", None)
|
||||
fields.pop("encrypted_document_symmetric_key_for_user", None)
|
||||
|
||||
return fields
|
||||
|
||||
def validate_id(self, value):
|
||||
@@ -285,7 +344,15 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"attachments" field for access control.
|
||||
"""
|
||||
content = self.validated_data.get("content", "")
|
||||
extracted_attachments = set(utils.extract_attachments(content))
|
||||
|
||||
# Encrypted content cannot be parsed as a Yjs update
|
||||
# TODO: for now skip attachment extraction for encrypted documents but we should have them
|
||||
is_encrypted = self.validated_data.get(
|
||||
"is_encrypted", self.instance and self.instance.is_encrypted
|
||||
)
|
||||
extracted_attachments = (
|
||||
set() if is_encrypted else set(utils.extract_attachments(content))
|
||||
)
|
||||
|
||||
existing_attachments = (
|
||||
set(self.instance.attachments or []) if self.instance else set()
|
||||
@@ -343,6 +410,12 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
|
||||
max_role = serializers.SerializerMethodField(read_only=True)
|
||||
encrypted_document_symmetric_key_for_user = serializers.CharField(
|
||||
required=False, allow_blank=True, write_only=True
|
||||
)
|
||||
encryption_public_key_fingerprint = serializers.CharField(
|
||||
required=False, allow_blank=True, max_length=16
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.DocumentAccess
|
||||
@@ -357,6 +430,8 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"abilities",
|
||||
"max_ancestors_role",
|
||||
"max_role",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"encryption_public_key_fingerprint",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
@@ -366,6 +441,29 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"max_role",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
"""Dynamically control field availability and requirements based on document encryption status."""
|
||||
fields = super().get_fields()
|
||||
|
||||
# Get the document from context (if available)
|
||||
document = None
|
||||
if "view" in self.context and hasattr(self.context["view"], "document"):
|
||||
document = self.context["view"].document
|
||||
|
||||
# Get the encrypted_document_symmetric_key_for_user field
|
||||
key_field = fields.get("encrypted_document_symmetric_key_for_user")
|
||||
|
||||
if key_field:
|
||||
# If document is encrypted, make the field required
|
||||
if document and getattr(document, "is_encrypted", False):
|
||||
key_field.required = True
|
||||
key_field.allow_blank = False
|
||||
# If document is not encrypted, remove the field entirely
|
||||
elif document and not getattr(document, "is_encrypted", False):
|
||||
fields.pop("encrypted_document_symmetric_key_for_user", None)
|
||||
|
||||
return fields
|
||||
|
||||
def get_abilities(self, instance) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
@@ -616,6 +714,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
"""Receive file upload requests."""
|
||||
|
||||
file = serializers.FileField()
|
||||
is_encrypted = serializers.BooleanField(default=False, required=False)
|
||||
|
||||
def validate_file(self, file):
|
||||
"""Add file size and type constraints as defined in settings."""
|
||||
@@ -626,6 +725,22 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
f"File size exceeds the maximum limit of {max_size:d} MB."
|
||||
)
|
||||
|
||||
# For encrypted files, the content is ciphertext so MIME detection
|
||||
# is not possible. Trust the original filename extension.
|
||||
if self.initial_data.get("is_encrypted") in ("true", "True", True):
|
||||
extension = (
|
||||
file.name.rpartition(".")[-1] if "." in file.name else None
|
||||
)
|
||||
if extension is None or len(extension) > 5:
|
||||
raise serializers.ValidationError(
|
||||
"Could not determine file extension."
|
||||
)
|
||||
self.context["expected_extension"] = extension
|
||||
self.context["content_type"] = "application/octet-stream"
|
||||
self.context["is_unsafe"] = False
|
||||
self.context["file_name"] = file.name
|
||||
return file
|
||||
|
||||
extension = file.name.rpartition(".")[-1] if "." in file.name else None
|
||||
|
||||
# Read the first few bytes to determine the MIME type accurately
|
||||
@@ -856,6 +971,65 @@ class MoveDocumentSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class EncryptDocumentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for encrypting a document.
|
||||
|
||||
Fields:
|
||||
- content (CharField): The encrypted content of the document.
|
||||
This field is required.
|
||||
- encryptedSymmetricKeyPerUser (DictField): Mapping of user IDs to their encrypted symmetric keys.
|
||||
This field is required.
|
||||
|
||||
Example:
|
||||
Input payload for encrypting a document:
|
||||
{
|
||||
"content": "<encrypted_content>",
|
||||
"encryptedSymmetricKeyPerUser": {
|
||||
"user1_id": "encrypted_key_1",
|
||||
"user2_id": "encrypted_key_2"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
content = serializers.CharField(required=True)
|
||||
encryptedSymmetricKeyPerUser = serializers.DictField(child=serializers.CharField(), required=True)
|
||||
attachmentKeyMapping = serializers.DictField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
default=dict,
|
||||
help_text="Mapping of original attachment key to new encrypted attachment key. "
|
||||
"During encryption, existing attachments are uploaded encrypted under new keys. "
|
||||
"This mapping tells the backend to copy each new key over the original and clean up.",
|
||||
)
|
||||
|
||||
|
||||
class RemoveEncryptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for removing encryption from a document.
|
||||
|
||||
Fields:
|
||||
- content (CharField): The decrypted content of the document.
|
||||
This field is required.
|
||||
|
||||
Example:
|
||||
Input payload for removing encryption from a document:
|
||||
{
|
||||
"content": "<decrypted_content>"
|
||||
}
|
||||
"""
|
||||
|
||||
content = serializers.CharField(required=True)
|
||||
attachmentKeyMapping = serializers.DictField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
default=dict,
|
||||
help_text="Mapping of old encrypted attachment key to new decrypted attachment key. "
|
||||
"During decryption, encrypted attachments are re-uploaded decrypted under new keys. "
|
||||
"This mapping tells the backend to remove the old keys and clean up.",
|
||||
)
|
||||
|
||||
|
||||
class ReactionSerializer(serializers.ModelSerializer):
|
||||
"""Serialize reactions."""
|
||||
|
||||
|
||||
@@ -168,6 +168,10 @@ class UserViewSet(
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
#
|
||||
# TODO: adjust update public key
|
||||
#
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.filter(is_active=True)
|
||||
serializer_class = serializers.UserSerializer
|
||||
@@ -354,6 +358,19 @@ class DocumentViewSet(
|
||||
Returns: JSON response with the translated text.
|
||||
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
|
||||
|
||||
12. **Encrypt**: Encrypt a document.
|
||||
Example: PATCH /documents/{id}/encrypt/
|
||||
Expected data:
|
||||
- content (str): The encrypted content.
|
||||
- encryptedSymmetricKeyPerUser (dict): Mapping of user IDs to encrypted symmetric keys.
|
||||
Returns: JSON response with the updated document.
|
||||
|
||||
13. **Remove Encryption**: Remove encryption from a document.
|
||||
Example: PATCH /documents/{id}/remove-encryption/
|
||||
Expected data:
|
||||
- content (str): The decrypted content.
|
||||
Returns: JSON response with the updated document.
|
||||
|
||||
### Ordering: created_at, updated_at, is_favorite, title
|
||||
|
||||
Example:
|
||||
@@ -365,11 +382,18 @@ class DocumentViewSet(
|
||||
- `is_creator_me=false`: Returns documents created by other users.
|
||||
- `is_favorite=true`: Returns documents marked as favorite by the current user
|
||||
- `is_favorite=false`: Returns documents not marked as favorite by the current user
|
||||
- `is_encrypted=true`: Returns documents encrypted
|
||||
- `is_encrypted=false`: Returns documents not encrypted
|
||||
- `title=hello`: Returns documents which title contains the "hello" string
|
||||
|
||||
Example:
|
||||
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello&is_encrypted=false
|
||||
|
||||
### Encryption Management:
|
||||
The encryption status of documents can be managed using the dedicated endpoints:
|
||||
- PATCH /documents/{id}/encrypt/ - Set is_encrypted to true
|
||||
- PATCH /documents/{id}/remove-encryption/ - Set is_encrypted to false
|
||||
|
||||
### Annotations:
|
||||
1. **is_favorite**: Indicates whether the document is marked as favorite by the current user.
|
||||
@@ -613,6 +637,20 @@ class DocumentViewSet(
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Check rules about collaboration."""
|
||||
content_encrypted = serializer.validated_data.pop("contentEncrypted", None)
|
||||
if (
|
||||
content_encrypted is not None
|
||||
and content_encrypted != serializer.instance.is_encrypted
|
||||
):
|
||||
raise drf.exceptions.ValidationError(
|
||||
{
|
||||
"contentEncrypted": (
|
||||
"Content encryption status does not match the document's "
|
||||
"current state. Please refresh and try again."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
serializer.validated_data.get("websocket", False)
|
||||
or not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
|
||||
@@ -1347,6 +1385,14 @@ class DocumentViewSet(
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError(
|
||||
{
|
||||
"detail": "Visibility cannot be changed for encrypted documents. "
|
||||
"Encrypted documents must remain restricted.",
|
||||
}
|
||||
)
|
||||
|
||||
# Deserialize and validate the data
|
||||
serializer = serializers.LinkDocumentSerializer(
|
||||
document, data=request.data, partial=True
|
||||
@@ -1442,18 +1488,34 @@ class DocumentViewSet(
|
||||
serializer = serializers.FileUploadSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
ext = serializer.validated_data["expected_extension"]
|
||||
# Normally encrypted attachments would be only allowed on encrypted documents and vice-versa
|
||||
# but since during encryption/decryption we upload all attachments before the switch, we cannot enforce this rule
|
||||
is_file_encrypted = serializer.validated_data.get("is_encrypted", False)
|
||||
|
||||
# For encrypted files, set status to READY immediately since the server
|
||||
# cannot inspect ciphertext for malware scanning.
|
||||
initial_status = (
|
||||
enums.DocumentAttachmentStatus.READY
|
||||
if is_file_encrypted
|
||||
else enums.DocumentAttachmentStatus.PROCESSING
|
||||
)
|
||||
|
||||
# Prepare metadata for storage
|
||||
extra_args = {
|
||||
"Metadata": {
|
||||
"owner": str(request.user.id),
|
||||
"status": enums.DocumentAttachmentStatus.PROCESSING,
|
||||
"status": initial_status,
|
||||
},
|
||||
"ContentType": serializer.validated_data["content_type"],
|
||||
}
|
||||
|
||||
if is_file_encrypted:
|
||||
extra_args["Metadata"]["is_encrypted"] = "true"
|
||||
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
ext = serializer.validated_data["expected_extension"]
|
||||
|
||||
file_unsafe = ""
|
||||
if serializer.validated_data["is_unsafe"]:
|
||||
extra_args["Metadata"]["is_unsafe"] = "true"
|
||||
@@ -1483,7 +1545,9 @@ class DocumentViewSet(
|
||||
document.attachments.append(key)
|
||||
document.save()
|
||||
|
||||
malware_detection.analyse_file(key, document_id=document.id)
|
||||
# Only run malware scan for unencrypted files
|
||||
if not is_file_encrypted:
|
||||
malware_detection.analyse_file(key, document_id=document.id)
|
||||
|
||||
url = reverse(
|
||||
"documents-media-check",
|
||||
@@ -1937,6 +2001,228 @@ class DocumentViewSet(
|
||||
}
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""
|
||||
Perform update with safety check for encryption state changes.
|
||||
|
||||
If contentEncrypted parameter is provided, it must match the current
|
||||
is_encrypted state to prevent accidental content overrides during
|
||||
encryption state transitions.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
# Prevent direct changes to is_encrypted field via PATCH
|
||||
# (encryption state should only be changed via /encrypt/ or /remove-encryption/ endpoints)
|
||||
if 'is_encrypted' in serializer.validated_data:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'is_encrypted':
|
||||
'Cannot modify is_encrypted directly. '
|
||||
'Use the /encrypt/ or /remove-encryption/ endpoints to manage encryption.'
|
||||
})
|
||||
|
||||
# Check if contentEncrypted parameter was provided
|
||||
content_encrypted = serializer.validated_data.get('contentEncrypted')
|
||||
|
||||
if content_encrypted is not None:
|
||||
# Get the current document instance
|
||||
document = self.get_object()
|
||||
|
||||
# Safety check: contentEncrypted must match current is_encrypted state
|
||||
if content_encrypted != document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'contentEncrypted':
|
||||
f'contentEncrypted must match current encryption state. '
|
||||
f'Current: is_encrypted={document.is_encrypted}, '
|
||||
f'Provided: contentEncrypted={content_encrypted}'
|
||||
})
|
||||
|
||||
# Proceed with normal update
|
||||
return super().perform_update(serializer)
|
||||
|
||||
@transaction.atomic
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["patch"],
|
||||
name="Encrypt a document",
|
||||
url_path="encrypt",
|
||||
)
|
||||
def encrypt(self, request, *args, **kwargs):
|
||||
"""
|
||||
PATCH /api/v1.0/documents/<resource_id>/encrypt/
|
||||
with expected data:
|
||||
- content: str (encrypted content)
|
||||
- encryptedSymmetricKeyPerUser: dict (user_id -> encrypted_key)
|
||||
Updates the document's content and marks it as encrypted.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
serializer = serializers.EncryptDocumentSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
content = serializer.validated_data["content"]
|
||||
encryptedSymmetricKeyPerUser = serializer.validated_data["encryptedSymmetricKeyPerUser"]
|
||||
attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {})
|
||||
|
||||
# Prevent encryption if the document is not restricted (private)
|
||||
if document.computed_link_reach != models.LinkReachChoices.RESTRICTED:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot encrypt a document that is not private. '
|
||||
'Please set the document access to "Restricted" before encrypting.'
|
||||
})
|
||||
|
||||
# Prevent encryption if there are pending invitations
|
||||
if document.invitations.exists():
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot encrypt a document with pending invitations. '
|
||||
'Please resolve all invitations before encrypting.'
|
||||
})
|
||||
|
||||
# Validate that all users with access have an encryption public key
|
||||
document_accesses = models.DocumentAccess.objects.filter(
|
||||
document=document, user__isnull=False
|
||||
).select_related('user')
|
||||
|
||||
users_without_public_key = [
|
||||
access.user.email or str(access.user_id)
|
||||
for access in document_accesses
|
||||
if not access.user.encryption_public_key
|
||||
]
|
||||
if users_without_public_key:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot encrypt a document when some members have not enabled '
|
||||
'encryption: ' + ', '.join(users_without_public_key) + '. '
|
||||
'All members must enable encryption in their account settings first.'
|
||||
})
|
||||
|
||||
# Validate that we have encrypted symmetric keys for all users with access
|
||||
# Keys in encryptedSymmetricKeyPerUser are keyed by the user's OIDC `sub`
|
||||
users_with_access = {str(access.user.sub) for access in document_accesses}
|
||||
|
||||
# Check that encryptedSymmetricKeyPerUser contains all required users
|
||||
provided_user_ids = set(encryptedSymmetricKeyPerUser.keys())
|
||||
missing_users = users_with_access - provided_user_ids
|
||||
|
||||
if missing_users:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptedSymmetricKeyPerUser':
|
||||
f'Missing encrypted keys for users with document access: {missing_users}. '
|
||||
f'All users must have encrypted symmetric keys when encrypting a document.'
|
||||
})
|
||||
|
||||
# Check for extra users that don't have access
|
||||
extra_users = provided_user_ids - users_with_access
|
||||
if extra_users:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptedSymmetricKeyPerUser':
|
||||
f'Encrypted keys provided for users without document access: {extra_users}. '
|
||||
f'Only users with access should have encrypted symmetric keys.'
|
||||
})
|
||||
|
||||
# Remove old unencrypted attachment keys from the allowed list.
|
||||
# The frontend uploaded encrypted copies under new keys and updated the
|
||||
# Yjs content to reference them.
|
||||
if attachment_key_mapping:
|
||||
old_keys = set(attachment_key_mapping.keys())
|
||||
document.attachments = [
|
||||
k for k in (document.attachments or []) if k not in old_keys
|
||||
]
|
||||
|
||||
# Update the document content and encryption status
|
||||
document.content = content # This will be cached and saved to object storage
|
||||
document.is_encrypted = True
|
||||
document.save()
|
||||
|
||||
# Clean up old S3 objects only after the DB transaction has committed,
|
||||
# so a deletion failure can never affect the encrypt operation.
|
||||
if attachment_key_mapping:
|
||||
def _cleanup_old_attachments():
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
for old_key in attachment_key_mapping:
|
||||
try:
|
||||
s3_client.delete_object(Bucket=bucket_name, Key=old_key)
|
||||
except ClientError:
|
||||
logger.warning("Failed to delete old attachment %s", old_key)
|
||||
|
||||
transaction.on_commit(_cleanup_old_attachments)
|
||||
|
||||
# Store the encrypted symmetric keys in DocumentAccess for each user
|
||||
# Keys are keyed by the user's OIDC `sub`, so look up by user__sub
|
||||
for sub, encrypted_key in encryptedSymmetricKeyPerUser.items():
|
||||
try:
|
||||
# Find the DocumentAccess record for this user and document
|
||||
access = models.DocumentAccess.objects.get(document=document, user__sub=sub)
|
||||
access.encrypted_document_symmetric_key_for_user = encrypted_key
|
||||
access.save()
|
||||
except models.DocumentAccess.DoesNotExist:
|
||||
# This should not happen due to our validation above, but keep as safety
|
||||
pass
|
||||
|
||||
# Return the updated document
|
||||
serializer = self.get_serializer(document)
|
||||
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@transaction.atomic
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["patch"],
|
||||
name="Remove encryption from a document",
|
||||
url_path="remove-encryption",
|
||||
)
|
||||
def remove_encryption(self, request, *args, **kwargs):
|
||||
"""
|
||||
PATCH /api/v1.0/documents/<resource_id>/remove-encryption/
|
||||
with expected data:
|
||||
- content: str (decrypted content)
|
||||
Updates the document's content and marks it as not encrypted.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
serializer = serializers.RemoveEncryptionSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
content = serializer.validated_data["content"]
|
||||
attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {})
|
||||
|
||||
# Remove old encrypted attachment keys from the allowed list.
|
||||
# The frontend uploaded decrypted copies under new keys and updated
|
||||
# the Yjs content to reference them.
|
||||
if attachment_key_mapping:
|
||||
old_keys = set(attachment_key_mapping.keys())
|
||||
document.attachments = [
|
||||
k for k in (document.attachments or []) if k not in old_keys
|
||||
]
|
||||
|
||||
# Update the document content and encryption status
|
||||
document.content = content # This will be cached and saved to object storage
|
||||
document.is_encrypted = False
|
||||
document.save()
|
||||
|
||||
# Clean up any stored encrypted keys
|
||||
models.DocumentAccess.objects.filter(document=document).update(
|
||||
encrypted_document_symmetric_key_for_user=None
|
||||
)
|
||||
|
||||
# Clean up old S3 objects only after the DB transaction has committed
|
||||
if attachment_key_mapping:
|
||||
def _cleanup_old_attachments():
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
for old_key in attachment_key_mapping:
|
||||
try:
|
||||
s3_client.delete_object(Bucket=bucket_name, Key=old_key)
|
||||
except ClientError:
|
||||
logger.warning("Failed to delete old attachment %s", old_key)
|
||||
|
||||
transaction.on_commit(_cleanup_old_attachments)
|
||||
|
||||
# Return the updated document
|
||||
serializer = self.get_serializer(document)
|
||||
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
@@ -2098,6 +2384,16 @@ class DocumentAccessViewSet(
|
||||
"Only owners of a document can assign other users as owners."
|
||||
)
|
||||
|
||||
# Handle encrypted_document_symmetric_key_for_user during creation
|
||||
if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data:
|
||||
if not self.document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encrypted_document_symmetric_key_for_user':
|
||||
'This field can only be provided when the document is encrypted.'
|
||||
})
|
||||
# For encrypted documents, allow the key to be provided
|
||||
# The key will be stored directly in the DocumentAccess record
|
||||
|
||||
access = serializer.save(document_id=self.kwargs["resource_id"])
|
||||
|
||||
if access.user:
|
||||
@@ -2112,6 +2408,14 @@ class DocumentAccessViewSet(
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update an access to the document and notify the collaboration server."""
|
||||
# Prevent direct modification of encrypted_document_symmetric_key_for_user
|
||||
# This field should only be managed at access creation or when rotating the document key
|
||||
if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encrypted_document_symmetric_key_for_user':
|
||||
'This field cannot be modified directly.'
|
||||
})
|
||||
|
||||
access = serializer.save()
|
||||
|
||||
access_user_id = None
|
||||
@@ -2221,6 +2525,15 @@ class InvitationViewset(
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save invitation to a document then send an email to the invited user."""
|
||||
# Prevent invitation creation for encrypted documents
|
||||
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot create invitations for encrypted documents. '
|
||||
'All invitations must be resolved before encrypting a document.'
|
||||
})
|
||||
|
||||
invitation = serializer.save()
|
||||
|
||||
invitation.document.send_invitation_email(
|
||||
@@ -2230,6 +2543,19 @@ class InvitationViewset(
|
||||
self.request.user.language or settings.LANGUAGE_CODE,
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update an invitation to a document."""
|
||||
# Prevent invitation updates for encrypted documents
|
||||
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot update invitations for encrypted documents. '
|
||||
'All invitations must be resolved before encrypting a document.'
|
||||
})
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
|
||||
class DocumentAskForAccessViewSet(
|
||||
drf.mixins.ListModelMixin,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.10 on 2026-02-23 10:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0028_remove_templateaccess_template_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='is_encrypted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='documentaccess',
|
||||
name='encrypted_document_symmetric_key_for_user',
|
||||
field=models.TextField(blank=True, help_text='Encrypted symmetric key for this document, specific to this user.', null=True, verbose_name='encrypted document symmetric key'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='encryption_public_key',
|
||||
field=models.TextField(blank=True, help_text='Public key for end-to-end encryption.', null=True, verbose_name='encryption public key'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add encryption_public_key_fingerprint to BaseAccess (DocumentAccess).
|
||||
|
||||
Stores the fingerprint of the user's public key at the time of sharing,
|
||||
allowing the frontend to detect key changes without relying solely on
|
||||
client-side TOFU. If the user's current key fingerprint differs from
|
||||
this stored value, the document access needs re-encryption.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0029_document_is_encrypted_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="documentaccess",
|
||||
name="encryption_public_key_fingerprint",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Fingerprint of the user's public key at the time of sharing. "
|
||||
"Used to detect key changes — if the user's current public key "
|
||||
"fingerprint differs from this value, the access needs re-encryption."
|
||||
),
|
||||
max_length=16,
|
||||
null=True,
|
||||
verbose_name="encryption public key fingerprint",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Remove encryption_public_key from User model.
|
||||
|
||||
Public keys are now managed by the centralized encryption service.
|
||||
Products should fetch public keys from the encryption service's API
|
||||
when needed (e.g. for encrypting a document for multiple users).
|
||||
|
||||
The fingerprint of the public key at share time is stored on
|
||||
DocumentAccess.encryption_public_key_fingerprint (added in 0030).
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0030_baseaccess_encryption_public_key_fingerprint"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="encryption_public_key",
|
||||
),
|
||||
]
|
||||
@@ -279,6 +279,23 @@ class BaseAccess(BaseModel):
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
||||
)
|
||||
encrypted_document_symmetric_key_for_user = models.TextField(
|
||||
_("encrypted document symmetric key"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Encrypted symmetric key for this document, specific to this user."),
|
||||
)
|
||||
encryption_public_key_fingerprint = models.CharField(
|
||||
_("encryption public key fingerprint"),
|
||||
max_length=16,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Fingerprint of the user's public key at the time of sharing. "
|
||||
"Used to detect key changes — if the user's current public key "
|
||||
"fingerprint differs from this value, the access needs re-encryption."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -361,6 +378,7 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
|
||||
excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True)
|
||||
is_encrypted = models.BooleanField(default=False)
|
||||
link_reach = models.CharField(
|
||||
max_length=20,
|
||||
choices=LinkReachChoices.choices,
|
||||
@@ -718,6 +736,39 @@ class Document(MP_Node, BaseModel):
|
||||
"""Actual link role on the document."""
|
||||
return self.computed_link_definition["link_role"]
|
||||
|
||||
@property
|
||||
def accesses_user_ids(self):
|
||||
"""
|
||||
Return the list of user IDs with access to this document.
|
||||
The frontend uses these IDs to fetch public keys from the
|
||||
centralized encryption service.
|
||||
"""
|
||||
return list(
|
||||
DocumentAccess.objects
|
||||
.filter(document=self, user__isnull=False)
|
||||
.values_list('user__sub', flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@property
|
||||
def accesses_fingerprints_per_user(self):
|
||||
"""
|
||||
Return the fingerprint of each user's public key at the time of sharing.
|
||||
This allows the frontend to detect key changes by comparing the
|
||||
fingerprint stored at share time with the current public key fingerprint.
|
||||
"""
|
||||
accesses = (
|
||||
DocumentAccess.objects
|
||||
.filter(document=self, user__isnull=False, encryption_public_key_fingerprint__isnull=False)
|
||||
.values_list('user__sub', 'encryption_public_key_fingerprint')
|
||||
)
|
||||
|
||||
return {
|
||||
str(sub): fingerprint
|
||||
for sub, fingerprint in accesses
|
||||
if fingerprint
|
||||
}
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the document.
|
||||
@@ -797,12 +848,14 @@ class Document(MP_Node, BaseModel):
|
||||
"descendants": can_get,
|
||||
"destroy": can_destroy,
|
||||
"duplicate": can_get and user.is_authenticated,
|
||||
"encrypt": is_owner_or_admin,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
"invite_owner": is_owner and not is_deleted,
|
||||
"mask": can_get and user.is_authenticated,
|
||||
"move": is_owner_or_admin and not is_deleted,
|
||||
"partial_update": can_update,
|
||||
"remove_encryption": is_owner_or_admin,
|
||||
"restore": is_owner,
|
||||
"retrieve": retrieve,
|
||||
"media_auth": can_get,
|
||||
|
||||
@@ -244,7 +244,12 @@ class SearchIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
# Encrypted content is ciphertext and it should never be indexed for search
|
||||
if document.is_encrypted:
|
||||
text_content = ""
|
||||
else:
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
|
||||
@@ -351,6 +351,7 @@ def test_api_documents_all_format():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
|
||||
@@ -46,6 +46,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -69,6 +70,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -122,6 +124,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -145,6 +148,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -217,6 +221,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -240,6 +245,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -298,6 +304,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -321,6 +328,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -406,6 +414,7 @@ def test_api_documents_children_list_authenticated_related_direct(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -429,6 +438,7 @@ def test_api_documents_children_list_authenticated_related_direct(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -490,6 +500,7 @@ def test_api_documents_children_list_authenticated_related_parent(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -513,6 +524,7 @@ def test_api_documents_children_list_authenticated_related_parent(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -626,6 +638,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -649,6 +662,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
|
||||
@@ -43,6 +43,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -68,6 +69,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -91,6 +93,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -143,6 +146,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -166,6 +170,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -189,6 +194,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -262,6 +268,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -285,6 +292,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -308,6 +316,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -366,6 +375,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -389,6 +399,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -412,6 +423,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -491,6 +503,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -514,6 +527,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -537,6 +551,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -596,6 +611,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -619,6 +635,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -642,6 +659,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -747,6 +765,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -770,6 +789,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -793,6 +813,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
|
||||
@@ -71,6 +71,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": True,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
|
||||
@@ -73,6 +73,7 @@ def test_api_documents_list_format():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": True,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
|
||||
@@ -312,6 +312,69 @@ def test_api_documents_list_filter_is_favorite_invalid():
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_encrypted
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_true():
|
||||
"""
|
||||
Authenticated users should be able to filter encrypted documents.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are encrypted
|
||||
for result in results:
|
||||
assert result["is_encrypted"] is True
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents not encrypted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
|
||||
# Ensure all results are not encrypted
|
||||
for result in results:
|
||||
assert result["is_encrypted"] is False
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_invalid():
|
||||
"""Filtering with an invalid `is_encrypted` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_masked
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -151,6 +152,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"depth": 3,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -260,6 +262,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -343,6 +346,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -458,6 +462,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 2,
|
||||
@@ -541,6 +546,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 2,
|
||||
@@ -698,6 +704,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
@@ -765,6 +772,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
@@ -832,6 +840,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
|
||||
@@ -54,6 +54,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -78,6 +79,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -102,6 +104,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": sibling1.excerpt,
|
||||
"id": str(sibling1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling1.is_encrypted,
|
||||
"link_reach": sibling1.link_reach,
|
||||
"link_role": sibling1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -126,6 +129,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": sibling2.excerpt,
|
||||
"id": str(sibling2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling2.is_encrypted,
|
||||
"link_reach": sibling2.link_reach,
|
||||
"link_role": sibling2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -146,6 +150,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 3,
|
||||
@@ -219,6 +224,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -243,6 +249,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -271,6 +278,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -293,6 +301,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -319,6 +328,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -341,6 +351,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -421,6 +432,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -443,6 +455,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -467,6 +480,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -487,6 +501,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -565,6 +580,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -589,6 +605,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -617,6 +634,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -639,6 +657,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -665,6 +684,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -687,6 +707,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -769,6 +790,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -791,6 +813,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -815,6 +838,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -835,6 +859,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -917,6 +942,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -941,6 +967,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -969,6 +996,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -991,6 +1019,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -1017,6 +1046,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1039,6 +1069,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -1129,6 +1160,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1151,6 +1183,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -1175,6 +1208,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1195,6 +1229,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
|
||||
@@ -239,6 +239,18 @@ def test_services_search_indexers_serialize_document_empty():
|
||||
assert result["title"] == ""
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_encrypted():
|
||||
"""Encrypted documents should have empty content to avoid indexing ciphertext."""
|
||||
document = factories.DocumentFactory(is_encrypted=True)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
assert result["size"] == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
|
||||
@@ -232,6 +232,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
@@ -281,6 +282,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
@@ -329,6 +331,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 14,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=true
|
||||
NEXT_PUBLIC_VAULT_URL=http://localhost:7201
|
||||
NEXT_PUBLIC_INTERFACE_URL=http://localhost:7202
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@sentry/nextjs": "10.34.0",
|
||||
"@tanstack/react-query": "5.90.18",
|
||||
"@tiptap/extensions": "*",
|
||||
"async-mutex": "^0.5.0",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -70,6 +71,7 @@
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"y-protocols": "1.0.7",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "*",
|
||||
"zustand": "5.0.10"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useEffect } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||
import { UserEncryptionProvider } from '@/features/docs/doc-collaboration';
|
||||
import { VaultClientProvider } from '@/features/docs/doc-collaboration/vault';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { ConfigProvider } from './config/';
|
||||
@@ -74,7 +76,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CunninghamProvider theme={theme}>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
<Auth>
|
||||
<VaultClientProvider>
|
||||
<UserEncryptionProvider>{children}</UserEncryptionProvider>
|
||||
</VaultClientProvider>
|
||||
</Auth>
|
||||
</ConfigProvider>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* @interface User
|
||||
* @property {string} id - The id of the user.
|
||||
* @property {string} email - The email of the user.
|
||||
* @property {string} name - The name of the user.
|
||||
* @property {string} full_name - The full name of the user.
|
||||
* @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
suite_user_id: string | null;
|
||||
email: string;
|
||||
full_name: string;
|
||||
short_name: string;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, DropdownMenu, DropdownMenuOption, Icon } from '@/components';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
import { useAuth } from '../hooks';
|
||||
import { gotoLogout } from '../utils';
|
||||
|
||||
import { ModalEncryptionOnboarding } from './ModalEncryptionOnboarding';
|
||||
import { ModalEncryptionSettings } from './ModalEncryptionSettings';
|
||||
|
||||
export const AccountMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { hasKeys } = useVaultClient();
|
||||
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
// hasKeys comes from the vault — true if the user has encryption keys on this device
|
||||
const hasEncryptionSetup = hasKeys === true;
|
||||
|
||||
const encryptionOption: DropdownMenuOption = useMemo(() => {
|
||||
if (hasEncryptionSetup) {
|
||||
return {
|
||||
label: t('Encryption settings'),
|
||||
icon: 'lock',
|
||||
callback: () => setIsSettingsOpen(true),
|
||||
showSeparator: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: t('Enable encryption'),
|
||||
icon: 'lock_open',
|
||||
callback: () => setIsOnboardingOpen(true),
|
||||
showSeparator: true,
|
||||
};
|
||||
}, [hasEncryptionSetup, t]);
|
||||
|
||||
const options: DropdownMenuOption[] = useMemo(
|
||||
() => [
|
||||
encryptionOption,
|
||||
{
|
||||
label: t('Logout'),
|
||||
icon: 'logout',
|
||||
callback: () => gotoLogout(),
|
||||
},
|
||||
],
|
||||
[encryptionOption, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
showArrow
|
||||
label={t('My account')}
|
||||
buttonCss={css`
|
||||
transition: all var(--c--globals--transitions--duration)
|
||||
var(--c--globals--transitions--ease-out) !important;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
padding: 0.5rem 0.6rem;
|
||||
& > div {
|
||||
gap: 0.2rem;
|
||||
display: flex;
|
||||
}
|
||||
& .material-icons {
|
||||
color: var(
|
||||
--c--contextuals--content--palette--brand--primary
|
||||
) !important;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$theme="brand"
|
||||
$variation="tertiary"
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$align="center"
|
||||
>
|
||||
<Icon iconName="person" $color="inherit" $size="xl" />
|
||||
{t('My account')}
|
||||
</Box>
|
||||
</DropdownMenu>
|
||||
{user && isOnboardingOpen && (
|
||||
<ModalEncryptionOnboarding
|
||||
isOpen
|
||||
onClose={() => setIsOnboardingOpen(false)}
|
||||
onSuccess={() => setIsOnboardingOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{user && isSettingsOpen && (
|
||||
<ModalEncryptionSettings
|
||||
isOpen
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
onRequestReOnboard={() => {
|
||||
setIsSettingsOpen(false);
|
||||
setIsOnboardingOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,17 +2,16 @@ import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { BoxButton } from '@/components';
|
||||
|
||||
import ProConnectImg from '../assets/button-proconnect.svg';
|
||||
import { useAuth } from '../hooks';
|
||||
import { gotoLogin, gotoLogout } from '../utils';
|
||||
import { gotoLogin } from '../utils';
|
||||
import { AccountMenu } from './AccountMenu';
|
||||
|
||||
export const ButtonLogin = () => {
|
||||
const { t } = useTranslation();
|
||||
const { authenticated } = useAuth();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
@@ -28,26 +27,7 @@ export const ButtonLogin = () => {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
.--docs--button-logout:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']} !important;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
onClick={gotoLogout}
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
aria-label={t('Logout')}
|
||||
className="--docs--button-logout"
|
||||
>
|
||||
{t('Logout')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
return <AccountMenu />;
|
||||
};
|
||||
|
||||
export const ProConnectButton = () => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Encryption onboarding modal — delegates to the centralized encryption service.
|
||||
*
|
||||
* Opens the encryption service's interface iframe which handles everything:
|
||||
* key generation, backup, restore, device transfer, and server registration.
|
||||
* The product (Docs) doesn't manage public keys — it only stores fingerprints
|
||||
* on document accesses for UI purposes.
|
||||
*/
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useUserEncryption } from '@/docs/doc-collaboration';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
interface ModalEncryptionOnboardingProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const ModalEncryptionOnboarding = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ModalEncryptionOnboardingProps) => {
|
||||
const { client: vaultClient, refreshKeyState } = useVaultClient();
|
||||
const { refreshEncryption } = useUserEncryption();
|
||||
const onboardingOpenedRef = useRef(false);
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !vaultClient || !containerEl || onboardingOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onboardingOpenedRef.current = true;
|
||||
vaultClient.openOnboarding(containerEl);
|
||||
}, [isOpen, vaultClient, containerEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return;
|
||||
|
||||
const handleComplete = async () => {
|
||||
// The encryption service registered the public key on its central server.
|
||||
// Docs doesn't need to store it — just refresh the vault key state.
|
||||
await refreshKeyState();
|
||||
refreshEncryption();
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const handleClosed = () => {
|
||||
onboardingOpenedRef.current = false;
|
||||
onClose();
|
||||
};
|
||||
|
||||
vaultClient.on('onboarding:complete', handleComplete);
|
||||
vaultClient.on('interface:closed', handleClosed);
|
||||
|
||||
return () => {
|
||||
vaultClient.off('onboarding:complete', handleComplete);
|
||||
vaultClient.off('interface:closed', handleClosed);
|
||||
};
|
||||
}, [vaultClient, refreshKeyState, refreshEncryption, onSuccess, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
vaultClient?.closeInterface();
|
||||
onboardingOpenedRef.current = false;
|
||||
onClose();
|
||||
}, [vaultClient, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
onboardingOpenedRef.current = false;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
closeOnClickOutside={false}
|
||||
onClose={handleClose}
|
||||
size={ModalSize.LARGE}
|
||||
hideCloseButton
|
||||
>
|
||||
<Box $minHeight="400px">
|
||||
<div
|
||||
ref={setContainerEl}
|
||||
style={{ width: '100%', minHeight: '400px' }}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Encryption settings modal — delegates to the centralized encryption service.
|
||||
*
|
||||
* Opens the encryption service's settings interface iframe which handles:
|
||||
* fingerprint display, key deletion, device transfer export, and server key management.
|
||||
*/
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useUserEncryption } from '@/docs/doc-collaboration';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
interface ModalEncryptionSettingsProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRequestReOnboard: () => void;
|
||||
}
|
||||
|
||||
export const ModalEncryptionSettings = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onRequestReOnboard,
|
||||
}: ModalEncryptionSettingsProps) => {
|
||||
const { client: vaultClient, refreshKeyState } = useVaultClient();
|
||||
const { refreshEncryption } = useUserEncryption();
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||
const [settingsOpened, setSettingsOpened] = useState(false);
|
||||
|
||||
// Open the vault's settings interface when container is mounted
|
||||
useEffect(() => {
|
||||
if (!isOpen || !vaultClient || !containerEl || settingsOpened) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSettingsOpened(true);
|
||||
vaultClient.openSettings(containerEl);
|
||||
}, [isOpen, vaultClient, containerEl, settingsOpened]);
|
||||
|
||||
// Listen for interface close and key changes
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return;
|
||||
|
||||
const handleClosed = () => {
|
||||
setSettingsOpened(false);
|
||||
refreshKeyState().then(() => refreshEncryption());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeysDestroyed = () => {
|
||||
refreshKeyState().then(() => refreshEncryption());
|
||||
};
|
||||
|
||||
vaultClient.on('interface:closed', handleClosed);
|
||||
vaultClient.on('keys-destroyed', handleKeysDestroyed);
|
||||
|
||||
return () => {
|
||||
vaultClient.off('interface:closed', handleClosed);
|
||||
vaultClient.off('keys-destroyed', handleKeysDestroyed);
|
||||
};
|
||||
}, [vaultClient, refreshKeyState, refreshEncryption, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
vaultClient?.closeInterface();
|
||||
setSettingsOpened(false);
|
||||
onClose();
|
||||
}, [vaultClient, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSettingsOpened(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
closeOnClickOutside={false}
|
||||
onClose={handleClose}
|
||||
size={ModalSize.LARGE}
|
||||
hideCloseButton
|
||||
>
|
||||
<Box $minHeight="400px">
|
||||
<div
|
||||
ref={setContainerEl}
|
||||
style={{ width: '100%', minHeight: '400px' }}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './AccountMenu';
|
||||
export * from './Auth';
|
||||
export * from './ButtonLogin';
|
||||
export * from './ModalEncryptionOnboarding';
|
||||
export * from './ModalEncryptionSettings';
|
||||
export * from './UserAvatar';
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* User encryption context provider.
|
||||
*
|
||||
* MIGRATION NOTE: This provider now bridges between the VaultClient SDK
|
||||
* and the existing encryption context interface used by downstream components.
|
||||
* The vault handles all key storage and crypto operations — we no longer
|
||||
* expose raw CryptoKey objects. Instead, `encryptionSettings` signals that
|
||||
* encryption is available, and components use the VaultClient directly for
|
||||
* encrypt/decrypt operations.
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
import { useVaultClient } from './vault';
|
||||
|
||||
export type EncryptionError =
|
||||
| 'missing_private_key'
|
||||
| 'missing_public_key'
|
||||
| null;
|
||||
|
||||
interface UserEncryptionContextValue {
|
||||
encryptionLoading: boolean;
|
||||
/**
|
||||
* Non-null when the user has encryption keys available.
|
||||
* NOTE: userPrivateKey and userPublicKey are no longer raw CryptoKey objects.
|
||||
* They are kept as null placeholders for type compatibility. Use the VaultClient
|
||||
* directly for all crypto operations.
|
||||
*/
|
||||
encryptionSettings: {
|
||||
userId: string;
|
||||
userPrivateKey: null;
|
||||
userPublicKey: null;
|
||||
} | null;
|
||||
encryptionError: EncryptionError;
|
||||
refreshEncryption: () => void;
|
||||
}
|
||||
|
||||
const UserEncryptionContext = createContext<UserEncryptionContextValue>({
|
||||
encryptionLoading: true,
|
||||
encryptionSettings: null,
|
||||
encryptionError: null,
|
||||
refreshEncryption: () => {},
|
||||
});
|
||||
|
||||
export const UserEncryptionProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { isReady, isLoading, hasKeys, error, refreshKeyState } =
|
||||
useVaultClient();
|
||||
const [, setRefreshTrigger] = useState(0);
|
||||
|
||||
const refreshEncryption = useCallback(() => {
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
void refreshKeyState();
|
||||
}, [refreshKeyState]);
|
||||
|
||||
// Derive the legacy context value from the VaultClient state
|
||||
let encryptionSettings: UserEncryptionContextValue['encryptionSettings'] =
|
||||
null;
|
||||
let encryptionError: EncryptionError = null;
|
||||
|
||||
if (isReady && user?.suite_user_id) {
|
||||
if (hasKeys) {
|
||||
encryptionSettings = {
|
||||
userId: user.suite_user_id,
|
||||
userPrivateKey: null, // Keys are in the vault — use VaultClient for crypto
|
||||
userPublicKey: null,
|
||||
};
|
||||
} else {
|
||||
encryptionError = 'missing_private_key';
|
||||
}
|
||||
} else if (!isLoading && error) {
|
||||
encryptionError = 'missing_private_key';
|
||||
}
|
||||
|
||||
return (
|
||||
<UserEncryptionContext.Provider
|
||||
value={{
|
||||
encryptionLoading: isLoading,
|
||||
encryptionSettings,
|
||||
encryptionError,
|
||||
refreshEncryption,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UserEncryptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUserEncryption = (): UserEncryptionContextValue =>
|
||||
useContext(UserEncryptionContext);
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Encrypted WebSocket wrapper for real-time Yjs collaboration.
|
||||
*
|
||||
* Uses the VaultClient SDK for all encrypt/decrypt operations via postMessage
|
||||
* to the vault iframe. All data transfers use ArrayBuffer for zero-copy
|
||||
* performance. The vault caches the decrypted symmetric key per session
|
||||
* so only the first message incurs the hybrid decapsulation cost.
|
||||
*/
|
||||
|
||||
|
||||
export class EncryptedWebSocket extends WebSocket {
|
||||
protected readonly vaultClient!: VaultClient;
|
||||
protected readonly encryptedSymmetricKey!: ArrayBuffer;
|
||||
protected readonly onSystemMessage?: (message: string) => void;
|
||||
|
||||
constructor(address: string | URL, protocols?: string | string[]) {
|
||||
super(address, protocols);
|
||||
|
||||
const originalAddEventListener = this.addEventListener.bind(this);
|
||||
|
||||
this.addEventListener = function <K extends keyof WebSocketEventMap>(
|
||||
type: K,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
if (type === 'message') {
|
||||
const wrappedListener: typeof listener = async (event) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messageEvent = event as any;
|
||||
|
||||
// System messages (strings) bypass encryption
|
||||
if (typeof messageEvent.data === 'string') {
|
||||
this.onSystemMessage?.(messageEvent.data as string);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(messageEvent.data instanceof ArrayBuffer)) {
|
||||
throw new Error(
|
||||
'WebSocket data should always be ArrayBuffer (binaryType)',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt directly with ArrayBuffer — no base64 conversion
|
||||
const { data: decryptedBuffer } =
|
||||
await this.vaultClient.decryptWithKey(
|
||||
messageEvent.data as ArrayBuffer,
|
||||
this.encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const decryptedData = new Uint8Array(decryptedBuffer);
|
||||
|
||||
if (typeof listener === 'function') {
|
||||
listener.call(this, { ...event, data: decryptedData });
|
||||
} else {
|
||||
listener.handleEvent.call(this, {
|
||||
...event,
|
||||
data: decryptedData,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WebSocket decrypt error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
originalAddEventListener('message', wrappedListener, options);
|
||||
} else {
|
||||
originalAddEventListener(type, listener, options);
|
||||
}
|
||||
};
|
||||
|
||||
// Block direct onmessage assignment
|
||||
let explicitlySetListener: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((this: WebSocket, handlerEvent: MessageEvent) => any) | null;
|
||||
null;
|
||||
|
||||
Object.defineProperty(this, 'onmessage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return explicitlySetListener;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|
||||
set(handler: ((handlerEvent: MessageEvent) => any) | null) {
|
||||
explicitlySetListener = null;
|
||||
|
||||
throw new Error(
|
||||
'"onmessage" should not be set directly. Use addEventListener instead. Run "yarn run patch-package"!',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sendSystemMessage(message: string) {
|
||||
super.send(message);
|
||||
}
|
||||
|
||||
send(message: Uint8Array<ArrayBuffer>) {
|
||||
// Encrypt directly with ArrayBuffer — no base64 conversion
|
||||
this.vaultClient
|
||||
.encryptWithKey(
|
||||
message.buffer as ArrayBuffer,
|
||||
this.encryptedSymmetricKey,
|
||||
)
|
||||
.then(({ encryptedData }) => {
|
||||
super.send(new Uint8Array(encryptedData));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('WebSocket encrypt error:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdaptedEncryptedWebsocketClass(options: {
|
||||
vaultClient: VaultClient;
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
onSystemMessage?: (message: string) => void;
|
||||
}) {
|
||||
return class extends EncryptedWebSocket {
|
||||
protected readonly vaultClient = options.vaultClient;
|
||||
protected readonly encryptedSymmetricKey = options.encryptedSymmetricKey;
|
||||
protected readonly onSystemMessage = options.onSystemMessage;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { userKeyPairAlgorithm } from './encryption';
|
||||
|
||||
export async function exportPrivateKeyAsJwk(
|
||||
privateKey: CryptoKey,
|
||||
): Promise<JsonWebKey> {
|
||||
return await crypto.subtle.exportKey('jwk', privateKey);
|
||||
}
|
||||
|
||||
export async function importPrivateKeyFromJwk(
|
||||
jwk: JsonWebKey,
|
||||
): Promise<CryptoKey> {
|
||||
return await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
jwk,
|
||||
{ name: userKeyPairAlgorithm, hash: 'SHA-256' },
|
||||
true,
|
||||
['decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function importPublicKeyFromJwk(
|
||||
jwk: JsonWebKey,
|
||||
): Promise<CryptoKey> {
|
||||
return await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
jwk,
|
||||
{ name: userKeyPairAlgorithm, hash: 'SHA-256' },
|
||||
true,
|
||||
['encrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportPublicKeyAsBase64(
|
||||
publicKey: CryptoKey,
|
||||
): Promise<string> {
|
||||
const rawPublicKey = await crypto.subtle.exportKey('spki', publicKey);
|
||||
|
||||
return Buffer.from(new Uint8Array(rawPublicKey)).toString('base64');
|
||||
}
|
||||
|
||||
// Derive a public JWK from a private JWK by removing private fields.
|
||||
export function derivePublicJwkFromPrivate(privateJwk: JsonWebKey): JsonWebKey {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { d, p, q, dp, dq, qi, ...publicJwk } = privateJwk;
|
||||
|
||||
return { ...publicJwk, key_ops: ['encrypt'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a JWK to a compact passphrase-like string.
|
||||
* This is a base64url encoding of the full JWK JSON - not a mnemonic,
|
||||
* but compact enough to be stored in a password manager.
|
||||
*/
|
||||
export function jwkToPassphrase(jwk: JsonWebKey): string {
|
||||
const json = JSON.stringify(jwk);
|
||||
const base64 = Buffer.from(json).toString('base64');
|
||||
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a passphrase string back to a JWK.
|
||||
*/
|
||||
export function passphraseToJwk(passphrase: string): JsonWebKey {
|
||||
const base64 = passphrase.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const json = Buffer.from(base64, 'base64').toString('utf-8');
|
||||
|
||||
return JSON.parse(json) as JsonWebKey;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export const userKeyPairAlgorithm = 'RSA-OAEP';
|
||||
export const documentSymmetricKeyAlgorithm = 'AES-GCM';
|
||||
|
||||
export async function generateUserKeyPair(): Promise<CryptoKeyPair> {
|
||||
return await crypto.subtle.generateKey(
|
||||
{
|
||||
name: userKeyPairAlgorithm,
|
||||
modulusLength: 4096,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
// generate a symmetric key for document encryption
|
||||
export async function generateSymmetricKey(): Promise<CryptoKey> {
|
||||
return await crypto.subtle.generateKey(
|
||||
{ name: documentSymmetricKeyAlgorithm, length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt a symmetric key with a user's public key
|
||||
export async function encryptSymmetricKey(
|
||||
symmetricKey: CryptoKey,
|
||||
publicKey: CryptoKey,
|
||||
): Promise<ArrayBuffer> {
|
||||
const raw = await crypto.subtle.exportKey('raw', symmetricKey);
|
||||
|
||||
// TODO:
|
||||
// TODO: should use something better than RSA-OAEP, but maybe WebCrypto is not enough (use downloaded library? "libsodium-wrappers" or so)
|
||||
// TODO:
|
||||
return await crypto.subtle.encrypt(
|
||||
{ name: userKeyPairAlgorithm },
|
||||
publicKey,
|
||||
raw,
|
||||
);
|
||||
}
|
||||
|
||||
// decrypt a symmetric key with the local private key
|
||||
export async function decryptSymmetricKey(
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
privateKey: CryptoKey,
|
||||
): Promise<CryptoKey> {
|
||||
const raw = await crypto.subtle.decrypt(
|
||||
{ name: userKeyPairAlgorithm },
|
||||
privateKey,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
raw,
|
||||
{ name: documentSymmetricKeyAlgorithm },
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
// encrypt content with a symmetric key
|
||||
export async function encryptContent(
|
||||
content: Uint8Array<ArrayBuffer>,
|
||||
symmetricKey: CryptoKey,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: documentSymmetricKeyAlgorithm,
|
||||
iv,
|
||||
},
|
||||
symmetricKey,
|
||||
content,
|
||||
);
|
||||
|
||||
// Prepend IV to ciphertext so the recipient can extract it for decryption
|
||||
const result = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
result.set(iv);
|
||||
result.set(new Uint8Array(ciphertext), iv.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// decrypt content with a symmetric key
|
||||
export async function decryptContent(
|
||||
encryptedContent: Uint8Array<ArrayBuffer>,
|
||||
symmetricKey: CryptoKey,
|
||||
): Promise<Uint8Array<ArrayBufferLike>> {
|
||||
const iv = encryptedContent.slice(0, 12);
|
||||
const ciphertext = encryptedContent.slice(12);
|
||||
const arrayBuffer = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: documentSymmetricKeyAlgorithm,
|
||||
iv,
|
||||
},
|
||||
symmetricKey,
|
||||
ciphertext,
|
||||
);
|
||||
|
||||
return new Uint8Array(arrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a SHA-256 fingerprint of a base64-encoded public key.
|
||||
* Returns a formatted hex string like "A1B2 C3D4 E5F6 7890".
|
||||
*/
|
||||
export async function computeKeyFingerprint(
|
||||
base64Key: string,
|
||||
): Promise<string> {
|
||||
const raw = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0));
|
||||
const hash = await crypto.subtle.digest('SHA-256', raw);
|
||||
const hex = Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return hex
|
||||
.slice(0, 16)
|
||||
.replace(/(.{4})/g, '$1 ')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// prepare encrypted symmetric keys for all users with access to a document
|
||||
export async function prepareEncryptedSymmetricKeysForUsers(
|
||||
symmetricKey: CryptoKey,
|
||||
accessesPublicKeysPerUser: Record<string, ArrayBuffer>,
|
||||
): Promise<Record<string, ArrayBuffer>> {
|
||||
const result: Record<string, ArrayBuffer> = {};
|
||||
|
||||
// encrypt the symmetric key for each user's public key
|
||||
for (const [userId, publicKey] of Object.entries(accessesPublicKeysPerUser)) {
|
||||
const usablePublicKey = await crypto.subtle.importKey(
|
||||
'spki',
|
||||
publicKey,
|
||||
{ name: userKeyPairAlgorithm, hash: 'SHA-256' },
|
||||
true,
|
||||
['encrypt'],
|
||||
);
|
||||
|
||||
result[userId] = await encryptSymmetricKey(symmetricKey, usablePublicKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IDBPDatabase, openDB } from 'idb';
|
||||
|
||||
const DB_NAME = 'encryption';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
// Store names
|
||||
export const STORE_PRIVATE_KEY = 'privateKey';
|
||||
export const STORE_PUBLIC_KEY = 'publicKey';
|
||||
export const STORE_KNOWN_PUBLIC_KEYS = 'knownPublicKeys';
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase> | null = null;
|
||||
|
||||
/**
|
||||
* Opens (or reuses) the encryption IndexedDB with all required object stores.
|
||||
* Uses a singleton promise so the upgrade callback only runs once.
|
||||
*/
|
||||
export function getEncryptionDB(): Promise<IDBPDatabase> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_PRIVATE_KEY)) {
|
||||
db.createObjectStore(STORE_PRIVATE_KEY);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_PUBLIC_KEY)) {
|
||||
db.createObjectStore(STORE_PUBLIC_KEY);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_KNOWN_PUBLIC_KEYS)) {
|
||||
db.createObjectStore(STORE_KNOWN_PUBLIC_KEYS);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Hook to manage document-level encryption state.
|
||||
*
|
||||
* Stores the user's encrypted symmetric key as an ArrayBuffer.
|
||||
* Components pass this to VaultClient.encryptWithKey() / decryptWithKey()
|
||||
* for all crypto operations. The vault decrypts the symmetric key internally
|
||||
* using the user's private key (with session caching for performance).
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useUserEncryption } from '../UserEncryptionProvider';
|
||||
|
||||
export type DocumentEncryptionError =
|
||||
| 'missing_symmetric_key'
|
||||
| 'decryption_failed'
|
||||
| null;
|
||||
|
||||
export interface DocumentEncryptionSettings {
|
||||
/**
|
||||
* The user's encrypted symmetric key as ArrayBuffer.
|
||||
* Pass this to VaultClient.encryptWithKey() / decryptWithKey().
|
||||
*/
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
}
|
||||
|
||||
/** Convert a base64 string to ArrayBuffer */
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
export function useDocumentEncryption(
|
||||
isDocumentEncrypted: boolean | undefined,
|
||||
userEncryptedSymmetricKeyBase64: string | undefined,
|
||||
): {
|
||||
documentEncryptionLoading: boolean;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
documentEncryptionError: DocumentEncryptionError;
|
||||
} {
|
||||
const { encryptionLoading, encryptionSettings } = useUserEncryption();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<DocumentEncryptionError>(null);
|
||||
|
||||
// Convert the base64 key from the API to ArrayBuffer (memoized)
|
||||
const encryptedSymmetricKey = useMemo(() => {
|
||||
if (!userEncryptedSymmetricKeyBase64) return null;
|
||||
|
||||
try {
|
||||
return base64ToArrayBuffer(userEncryptedSymmetricKeyBase64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [userEncryptedSymmetricKeyBase64]);
|
||||
|
||||
const settings = useMemo<DocumentEncryptionSettings | null>(() => {
|
||||
if (!encryptedSymmetricKey) return null;
|
||||
|
||||
return { encryptedSymmetricKey };
|
||||
}, [encryptedSymmetricKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!encryptionLoading && !encryptionSettings) {
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (encryptionLoading || isDocumentEncrypted === undefined) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDocumentEncrypted === false) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!encryptedSymmetricKey) {
|
||||
setError('missing_symmetric_key');
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}, [
|
||||
encryptionLoading,
|
||||
encryptionSettings,
|
||||
isDocumentEncrypted,
|
||||
encryptedSymmetricKey,
|
||||
]);
|
||||
|
||||
return {
|
||||
documentEncryptionLoading: loading,
|
||||
documentEncryptionSettings: error ? null : settings,
|
||||
documentEncryptionError: error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getEncryptionDB } from '../encryptionDB';
|
||||
|
||||
export type EncryptionError =
|
||||
| 'missing_private_key'
|
||||
| 'missing_public_key'
|
||||
| null;
|
||||
|
||||
export function useEncryption(
|
||||
userId?: string,
|
||||
refreshTrigger?: number,
|
||||
): {
|
||||
encryptionLoading: boolean;
|
||||
encryptionSettings: {
|
||||
userId: string;
|
||||
userPrivateKey: CryptoKey;
|
||||
userPublicKey: CryptoKey;
|
||||
} | null;
|
||||
encryptionError: EncryptionError;
|
||||
} {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<{
|
||||
userId: string;
|
||||
userPrivateKey: CryptoKey;
|
||||
userPublicKey: CryptoKey;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<EncryptionError>(null);
|
||||
|
||||
const enableEncryption: boolean = true; // TODO: this could be toggled for instances not needing encryption to save some requests
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function initEncryption() {
|
||||
// Waiting for asynchronous data before initializing encryption stuff
|
||||
if (!userId) {
|
||||
setLoading(true);
|
||||
setSettings(null);
|
||||
setError(null);
|
||||
return;
|
||||
} else if (enableEncryption === false) {
|
||||
setLoading(false);
|
||||
setSettings(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// We must first retrieve user keys locally
|
||||
const encryptionDatabase = await getEncryptionDB();
|
||||
|
||||
const userPrivateKey = await encryptionDatabase.get(
|
||||
'privateKey',
|
||||
`user:${userId}`,
|
||||
);
|
||||
|
||||
if (!userPrivateKey) {
|
||||
if (!cancelled) {
|
||||
setError('missing_private_key');
|
||||
setSettings(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const userPublicKey = await encryptionDatabase.get(
|
||||
'publicKey',
|
||||
`user:${userId}`,
|
||||
);
|
||||
|
||||
if (!userPublicKey) {
|
||||
if (!cancelled) {
|
||||
setError('missing_public_key');
|
||||
setSettings(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setSettings({
|
||||
userId: userId,
|
||||
userPrivateKey: userPrivateKey,
|
||||
userPublicKey: userPublicKey,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
if (!cancelled) {
|
||||
setSettings(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initEncryption();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, enableEncryption, refreshTrigger]);
|
||||
|
||||
return {
|
||||
encryptionLoading: loading,
|
||||
encryptionSettings: settings,
|
||||
encryptionError: error,
|
||||
};
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { computeKeyFingerprint } from '../encryption';
|
||||
|
||||
/**
|
||||
* Computes a SHA-256 fingerprint of a base64-encoded public key.
|
||||
* Returns a formatted hex string like "A1B2 C3D4 E5F6 7890", or null
|
||||
* if the key is not provided or still computing.
|
||||
*/
|
||||
export function useKeyFingerprint(
|
||||
base64Key: string | null | undefined,
|
||||
): string | null {
|
||||
const [fingerprint, setFingerprint] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!base64Key) {
|
||||
setFingerprint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
computeKeyFingerprint(base64Key).then((fp) => {
|
||||
if (!cancelled) {
|
||||
setFingerprint(fp);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [base64Key]);
|
||||
|
||||
return fingerprint;
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { STORE_KNOWN_PUBLIC_KEYS, getEncryptionDB } from '../encryptionDB';
|
||||
|
||||
export interface PublicKeyMismatch {
|
||||
userId: string;
|
||||
knownKey: string;
|
||||
currentKey: string;
|
||||
}
|
||||
|
||||
// module-level listener set to keep all hook instances in sync
|
||||
const registryListeners = new Set<() => void>();
|
||||
|
||||
function notifyRegistryUpdated() {
|
||||
registryListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
/**
|
||||
* TOFU (Trust On First Use) public key registry.
|
||||
*
|
||||
* - On first encounter, a user's public key is stored locally in IndexedDB.
|
||||
* - On subsequent encounters, if the key differs from the stored one, it is
|
||||
* flagged as a mismatch.
|
||||
* - The caller can accept a new key via `acceptNewKey(userId)`, which updates
|
||||
* the locally stored key.
|
||||
*
|
||||
* All instances stay in sync via a module-level listener set.
|
||||
*/
|
||||
export function usePublicKeyRegistry(
|
||||
accessesPublicKeysPerUser: Record<string, string> | undefined,
|
||||
currentUserId?: string,
|
||||
) {
|
||||
const [mismatches, setMismatches] = useState<PublicKeyMismatch[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// listen for updates from other hook instances
|
||||
useEffect(() => {
|
||||
const handler = () => setRefreshTrigger((prev) => prev + 1);
|
||||
|
||||
registryListeners.add(handler);
|
||||
|
||||
return () => {
|
||||
registryListeners.delete(handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessesPublicKeysPerUser) {
|
||||
setMismatches([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function checkKeys() {
|
||||
try {
|
||||
const db = await getEncryptionDB();
|
||||
const newMismatches: PublicKeyMismatch[] = [];
|
||||
|
||||
for (const [userId, currentKey] of Object.entries(
|
||||
accessesPublicKeysPerUser!,
|
||||
)) {
|
||||
// Skip the current user — they know about their own key changes
|
||||
if (currentUserId && userId === currentUserId) {
|
||||
// Still store the key so it stays up to date locally
|
||||
await db.put(STORE_KNOWN_PUBLIC_KEYS, currentKey, `user:${userId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const knownKey: string | undefined = await db.get(
|
||||
STORE_KNOWN_PUBLIC_KEYS,
|
||||
`user:${userId}`,
|
||||
);
|
||||
|
||||
if (!knownKey) {
|
||||
// First time seeing this user's key — trust on first use
|
||||
await db.put(STORE_KNOWN_PUBLIC_KEYS, currentKey, `user:${userId}`);
|
||||
} else if (knownKey !== currentKey) {
|
||||
newMismatches.push({ userId, knownKey, currentKey });
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setMismatches(newMismatches);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('usePublicKeyRegistry: failed to check keys', error);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
checkKeys();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessesPublicKeysPerUser, currentUserId, refreshTrigger]);
|
||||
|
||||
const acceptNewKey = useCallback(
|
||||
async (userId: string) => {
|
||||
const mismatch = mismatches.find((m) => m.userId === userId);
|
||||
if (!mismatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await getEncryptionDB();
|
||||
await db.put(
|
||||
STORE_KNOWN_PUBLIC_KEYS,
|
||||
mismatch.currentKey,
|
||||
`user:${userId}`,
|
||||
);
|
||||
|
||||
setMismatches((prev) => prev.filter((m) => m.userId !== userId));
|
||||
|
||||
// notify other instances to re-check
|
||||
notifyRegistryUpdated();
|
||||
},
|
||||
[mismatches],
|
||||
);
|
||||
|
||||
return {
|
||||
mismatches,
|
||||
hasMismatches: mismatches.length > 0,
|
||||
loading,
|
||||
acceptNewKey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export {
|
||||
computeKeyFingerprint,
|
||||
decryptContent,
|
||||
encryptContent,
|
||||
generateSymmetricKey,
|
||||
generateUserKeyPair,
|
||||
prepareEncryptedSymmetricKeysForUsers,
|
||||
encryptSymmetricKey,
|
||||
} from './encryption';
|
||||
export { getEncryptionDB } from './encryptionDB';
|
||||
export {
|
||||
useDocumentEncryption,
|
||||
type DocumentEncryptionError,
|
||||
} from './hook/useDocumentEncryption';
|
||||
export { useEncryption, type EncryptionError } from './hook/useEncryption';
|
||||
export {
|
||||
UserEncryptionProvider,
|
||||
useUserEncryption,
|
||||
} from './UserEncryptionProvider';
|
||||
export { useKeyFingerprint } from './hook/useKeyFingerprint';
|
||||
export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry';
|
||||
export {
|
||||
exportPrivateKeyAsJwk,
|
||||
importPrivateKeyFromJwk,
|
||||
importPublicKeyFromJwk,
|
||||
exportPublicKeyAsBase64,
|
||||
derivePublicJwkFromPrivate,
|
||||
jwkToPassphrase,
|
||||
passphraseToJwk,
|
||||
} from './encryption-backup';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
|
||||
export class RelayProvider extends WebsocketProvider {
|
||||
// since the RelayProvider has been added to manage encryption that skips Hocuspocus logic
|
||||
// we mimic the needed properties for `SwitchableProvider` to be usable and to avoid use extra intermediaries
|
||||
get document() {
|
||||
return this.doc;
|
||||
}
|
||||
|
||||
get configuration() {
|
||||
return {
|
||||
name: this.roomname,
|
||||
};
|
||||
}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* React context provider for the centralized encryption VaultClient SDK.
|
||||
*
|
||||
* The client SDK is loaded at runtime via a <script> tag from the vault domain
|
||||
* (data.encryption). Type declarations are provided by encryption-client.d.ts.
|
||||
*
|
||||
* This provider:
|
||||
* - Loads the client.js script from the vault URL
|
||||
* - Creates and initializes the VaultClient instance
|
||||
* - Sets auth context when the user logs in
|
||||
* - Tracks key state (hasKeys, publicKey)
|
||||
* - Provides the client to all downstream components
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
// Environment configuration
|
||||
const VAULT_URL =
|
||||
process.env.NEXT_PUBLIC_VAULT_URL ?? 'http://localhost:7201';
|
||||
const INTERFACE_URL =
|
||||
process.env.NEXT_PUBLIC_INTERFACE_URL ?? 'http://localhost:7202';
|
||||
|
||||
export interface VaultClientContextValue {
|
||||
/** The VaultClient instance, or null if not yet initialized */
|
||||
client: VaultClient | null;
|
||||
/** True once the vault iframe is ready AND auth context has been set */
|
||||
isReady: boolean;
|
||||
/** True while the vault is initializing */
|
||||
isLoading: boolean;
|
||||
/** Error message if initialization failed */
|
||||
error: string | null;
|
||||
/** Whether the current user has encryption keys on this device */
|
||||
hasKeys: boolean | null;
|
||||
/** The current user's public key, or null */
|
||||
publicKey: ArrayBuffer | null;
|
||||
/** Re-check key state (after onboarding, restore, etc.) */
|
||||
refreshKeyState: () => Promise<void>;
|
||||
}
|
||||
|
||||
const VaultClientContext = createContext<VaultClientContextValue>({
|
||||
client: null,
|
||||
isReady: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
hasKeys: null,
|
||||
publicKey: null,
|
||||
refreshKeyState: async () => {},
|
||||
});
|
||||
|
||||
/** Load the encryption client SDK script from the vault domain */
|
||||
function loadClientScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if already loaded
|
||||
if (window.EncryptionClient?.VaultClient) {
|
||||
resolve();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script tag already exists
|
||||
const existing = document.querySelector(
|
||||
`script[src="${VAULT_URL}/client.js"]`,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve());
|
||||
existing.addEventListener('error', () =>
|
||||
reject(new Error('Failed to load encryption client SDK')),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = `${VAULT_URL}/client.js`;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () =>
|
||||
reject(new Error('Failed to load encryption client SDK'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function VaultClientProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user, authenticated } = useAuth();
|
||||
const { i18n } = useTranslation();
|
||||
const { theme: cunninghamTheme } = useCunninghamTheme();
|
||||
const clientRef = useRef<VaultClient | null>(null);
|
||||
const [clientInitialized, setClientInitialized] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasKeys, setHasKeys] = useState<boolean | null>(null);
|
||||
const [publicKey, setPublicKey] = useState<ArrayBuffer | null>(null);
|
||||
const initRef = useRef(false);
|
||||
|
||||
// Load script + initialize VaultClient once
|
||||
useEffect(() => {
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
let destroyed = false;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await loadClientScript();
|
||||
|
||||
if (destroyed) return;
|
||||
|
||||
const client = new window.EncryptionClient.VaultClient({
|
||||
vaultUrl: VAULT_URL,
|
||||
interfaceUrl: INTERFACE_URL,
|
||||
theme: cunninghamTheme,
|
||||
lang: i18n.language,
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
|
||||
client.on('onboarding:complete', () => {
|
||||
setHasKeys(true);
|
||||
client
|
||||
.getPublicKey()
|
||||
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
client.on('keys-changed', () => {
|
||||
client
|
||||
.hasKeys()
|
||||
.then(({ hasKeys: exists }) => {
|
||||
setHasKeys(exists);
|
||||
|
||||
if (exists) {
|
||||
client
|
||||
.getPublicKey()
|
||||
.then(({ publicKey: pk }) => setPublicKey(pk))
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
client.on('keys-destroyed', () => {
|
||||
setHasKeys(false);
|
||||
setPublicKey(null);
|
||||
});
|
||||
|
||||
await client.init();
|
||||
|
||||
if (destroyed) {
|
||||
client.destroy();
|
||||
} else {
|
||||
setClientInitialized(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!destroyed) {
|
||||
setError((err as Error).message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
|
||||
if (clientRef.current) {
|
||||
clientRef.current.destroy();
|
||||
clientRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set auth context whenever user changes or client finishes initializing
|
||||
useEffect(() => {
|
||||
const client = clientRef.current;
|
||||
|
||||
if (
|
||||
!client ||
|
||||
!clientInitialized ||
|
||||
!authenticated ||
|
||||
!user?.id ||
|
||||
!user?.suite_user_id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function setupAuth() {
|
||||
if (cancelled || !client) return;
|
||||
|
||||
client.setAuthContext({
|
||||
suiteUserId: user!.suite_user_id!,
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hasKeys: exists } = await client.hasKeys();
|
||||
setHasKeys(exists);
|
||||
|
||||
if (exists) {
|
||||
const { publicKey: pk } = await client.getPublicKey();
|
||||
setPublicKey(pk);
|
||||
}
|
||||
|
||||
setIsReady(true);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void setupAuth();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [clientInitialized, authenticated, user?.id, user?.suite_user_id]);
|
||||
|
||||
const refreshKeyState = useCallback(async () => {
|
||||
const client = clientRef.current;
|
||||
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const { hasKeys: exists } = await client.hasKeys();
|
||||
setHasKeys(exists);
|
||||
|
||||
if (exists) {
|
||||
const { publicKey: pk } = await client.getPublicKey();
|
||||
setPublicKey(pk);
|
||||
} else {
|
||||
setPublicKey(null);
|
||||
}
|
||||
} catch {
|
||||
// Vault not available
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VaultClientContext.Provider
|
||||
value={{
|
||||
client: clientRef.current,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
hasKeys,
|
||||
publicKey,
|
||||
refreshKeyState,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</VaultClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useVaultClient = (): VaultClientContextValue =>
|
||||
useContext(VaultClientContext);
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Re-export types from encryption-client.d.ts for global availability
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface VaultClient {
|
||||
init(): Promise<void>;
|
||||
destroy(): void;
|
||||
setTheme(theme: string): void;
|
||||
setAuthContext(context: { suiteUserId: string }): void;
|
||||
hasKeys(): Promise<{ hasKeys: boolean }>;
|
||||
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>;
|
||||
encrypt(data: ArrayBuffer): Promise<{ encryptedData: ArrayBuffer }>;
|
||||
decrypt(encryptedData: ArrayBuffer): Promise<{ data: ArrayBuffer }>;
|
||||
encryptForUsers(data: ArrayBuffer, userPublicKeys: Record<string, ArrayBuffer>): Promise<{ encryptedContent: ArrayBuffer; encryptedKeys: Record<string, ArrayBuffer> }>;
|
||||
encryptWithKey(data: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer): Promise<{ encryptedData: ArrayBuffer }>;
|
||||
decryptWithKey(encryptedData: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer): Promise<{ data: ArrayBuffer }>;
|
||||
rewrapKey(encryptedSymmetricKey: ArrayBuffer, targetUserPublicKey: ArrayBuffer): Promise<{ rewrappedKey: ArrayBuffer }>;
|
||||
fetchPublicKeys(userIds: string[]): Promise<{ publicKeys: Record<string, ArrayBuffer> }>;
|
||||
checkFingerprints(userFingerprints: Record<string, string>, currentUserId?: string): Promise<{ results: Array<{ userId: string; knownFingerprint: string | null; providedFingerprint: string; status: 'trusted' | 'refused' | 'unknown' }> }>;
|
||||
acceptFingerprint(userId: string, fingerprint: string): Promise<void>;
|
||||
refuseFingerprint(userId: string, fingerprint: string): Promise<void>;
|
||||
getKnownFingerprints(): Promise<{ fingerprints: Record<string, { fingerprint: string; status: 'trusted' | 'refused' | 'unknown' }> }>;
|
||||
openOnboarding(container: HTMLElement): void;
|
||||
openBackup(container: HTMLElement): void;
|
||||
openRestore(container: HTMLElement): void;
|
||||
openDeviceTransfer(container: HTMLElement): void;
|
||||
openSettings(container: HTMLElement): void;
|
||||
closeInterface(): void;
|
||||
on<K extends string>(event: K, listener: (data: any) => void): void;
|
||||
off<K extends string>(event: K, listener: (data: any) => void): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
EncryptionClient: {
|
||||
VaultClient: new (options: {
|
||||
vaultUrl: string;
|
||||
interfaceUrl: string;
|
||||
timeout?: number;
|
||||
theme?: string;
|
||||
lang?: string;
|
||||
}) => VaultClient;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VaultClientProvider, useVaultClient } from './VaultClientProvider';
|
||||
export type { VaultClientContextValue } from './VaultClientProvider';
|
||||
@@ -78,7 +78,7 @@ describe('DocEditor', () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { rerender } = render(<DocEditor doc={doc} />, {
|
||||
const { rerender } = render(<DocEditor doc={doc} documentEncryptionSettings={null} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('DocEditor', () => {
|
||||
|
||||
// Rerender with same doc to check that event is not tracked again
|
||||
rerender(
|
||||
<DocEditor doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }} />,
|
||||
<DocEditor doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }} documentEncryptionSettings={null} />,
|
||||
);
|
||||
|
||||
expect(TrackEventMock).toHaveBeenNthCalledWith(1, {
|
||||
@@ -107,6 +107,7 @@ describe('DocEditor', () => {
|
||||
id: 'test-doc-id-2',
|
||||
computed_link_reach: LinkReach.RESTRICTED,
|
||||
}}
|
||||
documentEncryptionSettings={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
+68
-35
@@ -12,7 +12,6 @@ import * as locales from '@blocknote/core/locales';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import '@blocknote/mantine/style.css';
|
||||
import { useCreateBlockNote } from '@blocknote/react';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
@@ -20,8 +19,13 @@ import type { Awareness } from 'y-protocols/awareness';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, useProviderStore } from '@/docs/doc-management';
|
||||
import {
|
||||
Doc,
|
||||
SwitchableProvider,
|
||||
useProviderStore,
|
||||
} from '@/docs/doc-management';
|
||||
import { avatarUrlFromName, useAuth } from '@/features/auth';
|
||||
|
||||
import {
|
||||
@@ -37,13 +41,17 @@ import { DocsBlockNoteEditor } from '../types';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { EncryptedDocBanner } from './EncryptedDocBanner';
|
||||
import { EncryptionProvider } from './EncryptionProvider';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { cssComments, useComments } from './comments/';
|
||||
import {
|
||||
AccessibleImageBlock,
|
||||
AudioBlock,
|
||||
CalloutBlock,
|
||||
PdfBlock,
|
||||
UploadLoaderBlock,
|
||||
VideoBlock,
|
||||
} from './custom-blocks';
|
||||
import {
|
||||
InterlinkingLinkInlineContent,
|
||||
@@ -58,11 +66,13 @@ const baseBlockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
audio: AudioBlock(),
|
||||
callout: CalloutBlock(),
|
||||
codeBlock: createCodeBlockSpec(codeBlockOptions),
|
||||
image: AccessibleImageBlock(),
|
||||
pdf: PdfBlock(),
|
||||
uploadLoader: UploadLoaderBlock(),
|
||||
video: VideoBlock(),
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
@@ -77,10 +87,15 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
provider: HocuspocusProvider;
|
||||
provider: SwitchableProvider;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
export const BlockNoteEditor = ({
|
||||
doc,
|
||||
provider,
|
||||
documentEncryptionSettings,
|
||||
}: BlockNoteEditorProps) => {
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -91,14 +106,21 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
// Determine if comments should be visible in the UI
|
||||
const showComments = canSeeComment;
|
||||
|
||||
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
|
||||
useSaveDoc(
|
||||
doc.id,
|
||||
provider.document,
|
||||
isConnectedToCollabServer,
|
||||
doc.is_encrypted,
|
||||
documentEncryptionSettings,
|
||||
);
|
||||
const { i18n } = useTranslation();
|
||||
let lang = i18n.resolvedLanguage;
|
||||
if (!lang || !(lang in locales)) {
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
|
||||
const encryptedSymmetricKey = documentEncryptionSettings?.encryptedSymmetricKey;
|
||||
const { uploadFile, errorAttachment } = useUploadFile(doc.id, encryptedSymmetricKey);
|
||||
|
||||
const collabName = user?.full_name || user?.email;
|
||||
const cursorName = collabName || t('Anonymous');
|
||||
@@ -200,7 +222,15 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
uploadFile,
|
||||
schema: blockNoteSchema,
|
||||
},
|
||||
[cursorName, lang, provider, uploadFile, threadStore, resolveUsers],
|
||||
[
|
||||
cursorName,
|
||||
lang,
|
||||
provider,
|
||||
uploadFile,
|
||||
encryptedSymmetricKey,
|
||||
threadStore,
|
||||
resolveUsers,
|
||||
],
|
||||
);
|
||||
|
||||
useHeadings(editor);
|
||||
@@ -218,35 +248,38 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
}, [setEditor, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
causes={errorAttachment.cause}
|
||||
canClose
|
||||
$textAlign="left"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
theme="light"
|
||||
comments={showComments}
|
||||
aria-label={t('Document editor')}
|
||||
<EncryptionProvider encryptedSymmetricKey={encryptedSymmetricKey}>
|
||||
<EncryptedDocBanner />
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
causes={errorAttachment.cause}
|
||||
canClose
|
||||
$textAlign="left"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
theme="light"
|
||||
comments={showComments}
|
||||
aria-label={t('Document editor')}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
</EncryptionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+22
-16
@@ -24,6 +24,7 @@ import {
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema,
|
||||
} from '../../types';
|
||||
import { useEncryption } from '../EncryptionProvider';
|
||||
|
||||
export const FileDownloadButton = ({
|
||||
open,
|
||||
@@ -32,6 +33,7 @@ export const FileDownloadButton = ({
|
||||
}) => {
|
||||
const dict = useDictionary();
|
||||
const Components = useComponentsContext();
|
||||
const { isEncrypted, decryptFileUrl } = useEncryption();
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
DocsBlockSchema,
|
||||
@@ -75,30 +77,34 @@ export const FileDownloadButton = ({
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
* we do what Blocknote was doing initially.
|
||||
*
|
||||
* For this case, no need of adding decryption logic
|
||||
*/
|
||||
if (!url.includes(window.location.hostname) && !url.includes('base64')) {
|
||||
if (!editor.resolveFileUrl) {
|
||||
if (!isSafeUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
void editor
|
||||
.resolveFileUrl(url)
|
||||
.then((downloadUrl) => window.open(downloadUrl));
|
||||
if (!isSafeUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchAndDownload = async (fileName: string) => {
|
||||
if (isEncrypted) {
|
||||
const blobUrl = await decryptFileUrl(url);
|
||||
const blob = await fetch(blobUrl).then((r) => r.blob());
|
||||
downloadFile(blob, fileName);
|
||||
} else {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(blob, fileName);
|
||||
}
|
||||
};
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(blob, name || url.split('/').pop() || 'file');
|
||||
await fetchAndDownload(name || url.split('/').pop() || 'file');
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
|
||||
const baseName = name || url.split('/').pop() || 'file';
|
||||
|
||||
const regFindLastDot = /(\.[^/.]+)$/;
|
||||
@@ -106,13 +112,13 @@ export const FileDownloadButton = ({
|
||||
? baseName.replace(regFindLastDot, '-unsafe$1')
|
||||
: baseName + '-unsafe';
|
||||
|
||||
downloadFile(blob, unsafeName);
|
||||
await fetchAndDownload(unsafeName);
|
||||
};
|
||||
|
||||
open(onConfirm);
|
||||
}
|
||||
}
|
||||
}, [editor, fileBlock, open]);
|
||||
}, [editor, fileBlock, open, isEncrypted, decryptFileUrl]);
|
||||
|
||||
if (!fileBlock || fileBlock.props.url === '' || !Components) {
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Box, Loading } from '@/components';
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { DocHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
@@ -76,9 +77,13 @@ export const DocEditorContainer = ({
|
||||
|
||||
interface DocEditorProps {
|
||||
doc: Doc;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
export const DocEditor = ({
|
||||
doc,
|
||||
documentEncryptionSettings,
|
||||
}: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
@@ -130,7 +135,12 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
<>
|
||||
{isDesktop && <TableContent />}
|
||||
<DocEditorContainer
|
||||
docHeader={<DocHeader doc={doc} />}
|
||||
docHeader={
|
||||
<DocHeader
|
||||
doc={doc}
|
||||
documentEncryptionSettings={documentEncryptionSettings}
|
||||
/>
|
||||
}
|
||||
docEditor={
|
||||
readOnly ? (
|
||||
<BlockNoteReader
|
||||
@@ -140,7 +150,11 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
docId={doc.id}
|
||||
/>
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} provider={provider} />
|
||||
<BlockNoteEditor
|
||||
doc={doc}
|
||||
provider={provider}
|
||||
documentEncryptionSettings={documentEncryptionSettings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
isDeletedDoc={isDeletedDoc}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
|
||||
import { useEncryption } from './EncryptionProvider';
|
||||
|
||||
export const EncryptedDocBanner = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isEncrypted, pendingPlaceholders, requestRevealAll } =
|
||||
useEncryption();
|
||||
|
||||
if (!isEncrypted || pendingPlaceholders === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$justify="flex-end"
|
||||
$padding={{ horizontal: '54px', vertical: '3xs' }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={requestRevealAll}
|
||||
icon={<Icon iconName="visibility" $size="sm" $color="inherit" />}
|
||||
>
|
||||
{t('Reveal all media')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Loading } from '@/components';
|
||||
|
||||
interface EncryptedMediaPlaceholderProps {
|
||||
label: string;
|
||||
errorLabel: string;
|
||||
minHeight?: string;
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
onDecrypt: () => void;
|
||||
}
|
||||
|
||||
export const EncryptedMediaPlaceholder = ({
|
||||
label,
|
||||
errorLabel,
|
||||
minHeight = '200px',
|
||||
isLoading,
|
||||
hasError,
|
||||
onDecrypt,
|
||||
}: EncryptedMediaPlaceholderProps) => {
|
||||
if (hasError) {
|
||||
return (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$color="#666"
|
||||
$background="#f5f5f5"
|
||||
$border="1px solid #ddd"
|
||||
$minHeight={minHeight}
|
||||
$padding="20px"
|
||||
$css={css`
|
||||
text-align: center;
|
||||
`}
|
||||
contentEditable={false}
|
||||
>
|
||||
{errorLabel}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$color="#666"
|
||||
$background="#f5f5f5"
|
||||
$border="1px solid #ddd"
|
||||
$minHeight={minHeight}
|
||||
$padding="20px"
|
||||
$css={css`
|
||||
text-align: center;
|
||||
cursor: ${isLoading ? 'wait' : 'pointer'};
|
||||
position: relative;
|
||||
`}
|
||||
contentEditable={false}
|
||||
onClick={() => !isLoading && onDecrypt()}
|
||||
>
|
||||
<Icon iconName="lock" $size="24px" />
|
||||
<Box $margin={{ top: '2px' }}>{label}</Box>
|
||||
{isLoading && (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$css={css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(245, 245, 245, 0.8);
|
||||
`}
|
||||
>
|
||||
<Loading />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Encryption context for document content and file attachments.
|
||||
* Uses VaultClient SDK with ArrayBuffer for all decrypt operations.
|
||||
*/
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
aac: 'audio/aac',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogv: 'video/ogg',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
pdf: 'application/pdf',
|
||||
};
|
||||
|
||||
interface EncryptionContextValue {
|
||||
isEncrypted: boolean;
|
||||
decryptFileUrl: (url: string) => Promise<string>;
|
||||
revealAllCounter: number;
|
||||
requestRevealAll: () => void;
|
||||
pendingPlaceholders: number;
|
||||
registerPlaceholder: () => void;
|
||||
unregisterPlaceholder: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const DEFAULT_VALUE: EncryptionContextValue = {
|
||||
isEncrypted: false,
|
||||
decryptFileUrl: async (url: string) => url,
|
||||
revealAllCounter: 0,
|
||||
requestRevealAll: noop,
|
||||
pendingPlaceholders: 0,
|
||||
registerPlaceholder: noop,
|
||||
unregisterPlaceholder: noop,
|
||||
};
|
||||
|
||||
const EncryptionContext = createContext<EncryptionContextValue>(DEFAULT_VALUE);
|
||||
|
||||
interface EncryptionProviderProps {
|
||||
encryptedSymmetricKey: ArrayBuffer | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const EncryptionProvider = ({
|
||||
encryptedSymmetricKey,
|
||||
children,
|
||||
}: EncryptionProviderProps) => {
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const blobUrlCacheRef = useRef<Map<string, string>>(new Map());
|
||||
const [revealAllCounter, setRevealAllCounter] = useState(0);
|
||||
const [pendingPlaceholders, setPendingPlaceholders] = useState(0);
|
||||
|
||||
const requestRevealAll = useCallback(() => {
|
||||
setRevealAllCounter((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const registerPlaceholder = useCallback(() => {
|
||||
setPendingPlaceholders((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const unregisterPlaceholder = useCallback(() => {
|
||||
setPendingPlaceholders((c) => Math.max(0, c - 1));
|
||||
}, []);
|
||||
|
||||
const decryptFileUrl = useCallback(
|
||||
async (url: string): Promise<string> => {
|
||||
if (!encryptedSymmetricKey || !vaultClient) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const cached = blobUrlCacheRef.current.get(url);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch encrypted attachment: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get file as ArrayBuffer directly — no base64 conversion
|
||||
const encryptedBuffer = await response.arrayBuffer();
|
||||
|
||||
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
|
||||
encryptedBuffer,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const ext = url.split('.').pop()?.toLowerCase() || '';
|
||||
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
||||
|
||||
const blob = new Blob([decryptedBuffer], { type: mime });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlCacheRef.current.set(url, blobUrl);
|
||||
|
||||
return blobUrl;
|
||||
},
|
||||
[encryptedSymmetricKey, vaultClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobUrlCacheRef.current.forEach((blobUrl) =>
|
||||
URL.revokeObjectURL(blobUrl),
|
||||
);
|
||||
blobUrlCacheRef.current.clear();
|
||||
};
|
||||
}, [encryptedSymmetricKey]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isEncrypted: !!encryptedSymmetricKey,
|
||||
decryptFileUrl,
|
||||
revealAllCounter,
|
||||
requestRevealAll,
|
||||
pendingPlaceholders,
|
||||
registerPlaceholder,
|
||||
unregisterPlaceholder,
|
||||
}),
|
||||
[
|
||||
encryptedSymmetricKey,
|
||||
decryptFileUrl,
|
||||
revealAllCounter,
|
||||
requestRevealAll,
|
||||
pendingPlaceholders,
|
||||
registerPlaceholder,
|
||||
unregisterPlaceholder,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<EncryptionContext.Provider value={value}>
|
||||
{children}
|
||||
</EncryptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useEncryption = (): EncryptionContextValue =>
|
||||
useContext(EncryptionContext);
|
||||
+252
-88
@@ -1,130 +1,294 @@
|
||||
/**
|
||||
* AccessibleImageBlock.tsx
|
||||
*
|
||||
* This file defines a custom BlockNote block specification for an accessible image block.
|
||||
* It extends the default image block to ensure compliance with accessibility standards,
|
||||
* specifically RGAA 1.9.1, by using <figure> and <figcaption> elements when a caption is provided.
|
||||
* Custom BlockNote block for accessible images with encryption support.
|
||||
*
|
||||
* The accessible image block ensures that:
|
||||
* Accessibility (RGAA 1.9.1):
|
||||
* - Images with captions are wrapped in <figure> and <figcaption> elements.
|
||||
* - The <img> element has an appropriate alt attribute based on the caption.
|
||||
* - Accessibility attributes such as role and aria-label are added for better screen reader support.
|
||||
* - Images without captions have alt="" and are marked as decorative with aria-hidden="true".
|
||||
*
|
||||
* This implementation leverages BlockNote's existing image block functionality while enhancing it for accessibility.
|
||||
* Encryption:
|
||||
* - Images < 2MB are auto-decrypted inline.
|
||||
* - Images >= 2MB show a "click to decrypt" placeholder.
|
||||
*
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/blocks/Image/block.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockFromConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
ImageOptions,
|
||||
InlineContentSchema,
|
||||
InlineContentSchemaFromSpecs,
|
||||
StyleSchema,
|
||||
createBlockSpec,
|
||||
createImageBlockConfig,
|
||||
defaultInlineContentSpecs,
|
||||
imageParse,
|
||||
imageRender,
|
||||
imageToExternalHTML,
|
||||
} from '@blocknote/core';
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
ResizableFileBlockWrapper,
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type CreateImageBlockConfig = ReturnType<typeof createImageBlockConfig>;
|
||||
import { Icon, Loading } from '@/components';
|
||||
|
||||
export const accessibleImageRender =
|
||||
(config: ImageOptions) =>
|
||||
(
|
||||
block: BlockFromConfig<
|
||||
CreateImageBlockConfig,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>,
|
||||
editor: BlockNoteEditor<
|
||||
Record<'image', CreateImageBlockConfig>,
|
||||
InlineContentSchemaFromSpecs<typeof defaultInlineContentSpecs>,
|
||||
StyleSchema
|
||||
>,
|
||||
) => {
|
||||
const imageRenderComputed = imageRender(config);
|
||||
const dom = imageRenderComputed(block, editor).dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
import { ANALYZE_URL } from '../../conf';
|
||||
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
|
||||
import { useEncryption } from '../EncryptionProvider';
|
||||
|
||||
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
type ImageBlockConfig = ReturnType<typeof createImageBlockConfig>;
|
||||
|
||||
const figureElement = document.createElement('figure');
|
||||
const AUTOMATIC_DECRYPTION_MAX_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
// Copy all attributes from the original div
|
||||
figureElement.className = dom.className;
|
||||
const styleAttr = dom.getAttribute('style');
|
||||
if (styleAttr) {
|
||||
figureElement.setAttribute('style', styleAttr);
|
||||
}
|
||||
figureElement.style.setProperty('margin', '0');
|
||||
interface AccessibleImageProps {
|
||||
src: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
Array.from(dom.children).forEach((child) => {
|
||||
figureElement.appendChild(child.cloneNode(true));
|
||||
const AccessibleImage = ({ src, caption }: AccessibleImageProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (caption) {
|
||||
return (
|
||||
<figure
|
||||
style={{ margin: 0 }}
|
||||
role="img"
|
||||
aria-label={t('Image: {{title}}', { title: caption })}
|
||||
>
|
||||
<img
|
||||
className="bn-visual-media"
|
||||
src={src}
|
||||
alt={caption}
|
||||
tabIndex={0}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
/>
|
||||
<figcaption className="bn-file-caption">{caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="bn-visual-media"
|
||||
src={src}
|
||||
alt=""
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImageBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'image', ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'image', ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const ImageBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
...rest
|
||||
}: ImageBlockComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isEncrypted, decryptFileUrl } = useEncryption();
|
||||
|
||||
const url = block.props.url;
|
||||
const caption = block.props.caption || '';
|
||||
const isAnalyzing = !!url && url.includes(ANALYZE_URL);
|
||||
|
||||
// Encrypted state
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [showClickPlaceholder, setShowClickPlaceholder] = useState(false);
|
||||
|
||||
// Auto-decrypt small files, show placeholder for large ones
|
||||
useEffect(() => {
|
||||
if (!isEncrypted || !url || isAnalyzing) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
fetch(url, { method: 'HEAD', credentials: 'include' })
|
||||
.then(async (headResponse) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentLength = Number(
|
||||
headResponse.headers.get('content-length'),
|
||||
);
|
||||
|
||||
// Larger images show a "click to decrypt" placeholder instead to save decryption processing
|
||||
// (needed since photos taken from a smartphone can easily be over 15MB)
|
||||
if (contentLength < AUTOMATIC_DECRYPTION_MAX_SIZE) {
|
||||
try {
|
||||
const blobUrl = await decryptFileUrl(url);
|
||||
|
||||
if (!cancelled) {
|
||||
setResolvedUrl(blobUrl);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setShowClickPlaceholder(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) {
|
||||
setShowClickPlaceholder(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setShowClickPlaceholder(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Replace the <p> caption with <figcaption>
|
||||
const figcaptionElement = document.createElement('figcaption');
|
||||
const originalCaption = figureElement.querySelector('.bn-file-caption');
|
||||
if (originalCaption) {
|
||||
figcaptionElement.className = originalCaption.className;
|
||||
figcaptionElement.textContent = originalCaption.textContent;
|
||||
originalCaption.parentNode?.replaceChild(
|
||||
figcaptionElement,
|
||||
originalCaption,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isEncrypted, url, isAnalyzing, decryptFileUrl]);
|
||||
|
||||
// Add explicit role and aria-label for better screen reader support
|
||||
figureElement.setAttribute('role', 'img');
|
||||
figureElement.setAttribute(
|
||||
'aria-label',
|
||||
t(`Image: {{title}}`, { title: figcaptionElement.textContent }),
|
||||
);
|
||||
const handleDecrypt = useCallback(async () => {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
try {
|
||||
const blobUrl = await decryptFileUrl(url);
|
||||
setResolvedUrl(blobUrl);
|
||||
setShowClickPlaceholder(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [url, decryptFileUrl]);
|
||||
|
||||
// Remove the duplicate <p class="bn-file-caption"> added by ResizableFileBlockWrapper
|
||||
// when we render our own <figcaption> inside a <figure>.
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (!wrapperRef.current || !caption) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = wrapperRef.current.closest(
|
||||
'.bn-file-block-content-wrapper',
|
||||
);
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pCaption = wrapper.querySelector(':scope > p.bn-file-caption');
|
||||
if (pCaption) {
|
||||
pCaption.remove();
|
||||
}
|
||||
}, [caption]);
|
||||
|
||||
const effectiveUrl = isEncrypted ? resolvedUrl : url;
|
||||
const showMedia = !!effectiveUrl && !isAnalyzing;
|
||||
const showEncryptedPlaceholder =
|
||||
isEncrypted && (showClickPlaceholder || hasError) && !resolvedUrl;
|
||||
|
||||
return (
|
||||
<ResizableFileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
buttonIcon={
|
||||
<Icon iconName="image" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
>
|
||||
{isEncrypted && isLoading && !resolvedUrl && !showClickPlaceholder && (
|
||||
<Loading />
|
||||
)}
|
||||
{showEncryptedPlaceholder && (
|
||||
<EncryptedMediaPlaceholder
|
||||
label={t('Click to decrypt and view image')}
|
||||
errorLabel={t('Failed to decrypt image.')}
|
||||
isLoading={isLoading}
|
||||
hasError={hasError}
|
||||
onDecrypt={() => void handleDecrypt()}
|
||||
/>
|
||||
)}
|
||||
{showMedia && (
|
||||
<span ref={wrapperRef}>
|
||||
<AccessibleImage src={effectiveUrl} caption={caption} />
|
||||
</span>
|
||||
)}
|
||||
</ResizableFileBlockWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
// Return the figure element as the new dom
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom: figureElement,
|
||||
};
|
||||
};
|
||||
const ImageToExternalHTML = ({
|
||||
block,
|
||||
}: {
|
||||
block: BlockNoDefaults<
|
||||
Record<'image', ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}) => {
|
||||
if (!block.props.url) {
|
||||
return <p>Add image</p>;
|
||||
}
|
||||
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
const img = (
|
||||
<img
|
||||
src={block.props.url}
|
||||
alt={block.props.caption || ''}
|
||||
width={block.props.previewWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
if (block.props.caption) {
|
||||
return (
|
||||
<figure role="img" aria-label={block.props.caption}>
|
||||
{img}
|
||||
<figcaption>{block.props.caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
return img;
|
||||
};
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
return withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
};
|
||||
|
||||
export const AccessibleImageBlock = createBlockSpec(
|
||||
export const AccessibleImageBlock = createReactBlockSpec(
|
||||
createImageBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['image/*'],
|
||||
},
|
||||
render: accessibleImageRender(config),
|
||||
render: (props) => <ImageBlockComponent {...(props as any)} />,
|
||||
parse: imageParse(config),
|
||||
toExternalHTML: imageToExternalHTML(config),
|
||||
toExternalHTML: (props) => <ImageToExternalHTML {...(props as any)} />,
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
audioParse,
|
||||
createAudioBlockConfig,
|
||||
} from '@blocknote/core';
|
||||
import { FileBlockWrapper, createReactBlockSpec } from '@blocknote/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
|
||||
import { useDecryptMedia } from '../../hook';
|
||||
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
|
||||
|
||||
type AudioBlockConfig = ReturnType<typeof createAudioBlockConfig>;
|
||||
|
||||
interface AudioBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'audio', AudioBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'audio', AudioBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const AudioBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
...rest
|
||||
}: AudioBlockComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
showPlaceholder,
|
||||
showMedia,
|
||||
isLoading,
|
||||
hasError,
|
||||
decrypt,
|
||||
resolvedUrl,
|
||||
} = useDecryptMedia(block.props.url);
|
||||
|
||||
return (
|
||||
<FileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
buttonIcon={
|
||||
<Icon iconName="audiotrack" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
>
|
||||
{showPlaceholder && (
|
||||
<EncryptedMediaPlaceholder
|
||||
label={t('Click to decrypt and play audio')}
|
||||
errorLabel={t('Failed to decrypt audio file.')}
|
||||
minHeight="80px"
|
||||
isLoading={isLoading}
|
||||
hasError={hasError}
|
||||
onDecrypt={() => void decrypt()}
|
||||
/>
|
||||
)}
|
||||
{showMedia && (
|
||||
<audio
|
||||
className="bn-audio"
|
||||
src={resolvedUrl || block.props.url}
|
||||
controls
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</FileBlockWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioToExternalHTML = ({
|
||||
block,
|
||||
}: {
|
||||
block: BlockNoDefaults<
|
||||
Record<'audio', AudioBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}) => {
|
||||
if (!block.props.url) {
|
||||
return <p>Add audio</p>;
|
||||
}
|
||||
|
||||
return <audio src={block.props.url} controls />;
|
||||
};
|
||||
|
||||
export const AudioBlock = createReactBlockSpec(
|
||||
createAudioBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['audio/*'],
|
||||
},
|
||||
render: (props) => <AudioBlockComponent {...(props as any)} />,
|
||||
parse: audioParse(config),
|
||||
toExternalHTML: (props) => <AudioToExternalHTML {...(props as any)} />,
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
+46
-6
@@ -13,7 +13,7 @@ import {
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Box, Icon, Loading } from '@/components';
|
||||
|
||||
import { ANALYZE_URL } from '../../conf';
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
|
||||
import { useEncryption } from '../EncryptionProvider';
|
||||
|
||||
const PDFBlockStyle = createGlobalStyle`
|
||||
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
|
||||
@@ -67,9 +69,12 @@ const PdfBlockComponent = ({
|
||||
const pdfUrl = block.props.url;
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
const { isEncrypted, decryptFileUrl } = useEncryption();
|
||||
|
||||
const [isPDFContent, setIsPDFContent] = useState<boolean | null>(null);
|
||||
const [isPDFContentLoading, setIsPDFContentLoading] =
|
||||
useState<boolean>(false);
|
||||
const [resolvedPdfUrl, setResolvedPdfUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lang && locales[lang as keyof typeof locales]) {
|
||||
@@ -86,8 +91,9 @@ const PdfBlockComponent = ({
|
||||
}
|
||||
}, [lang, t]);
|
||||
|
||||
// For non-encrypted docs, validate PDF content on mount
|
||||
useEffect(() => {
|
||||
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
|
||||
if (isEncrypted || !pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,6 +107,7 @@ const PdfBlockComponent = ({
|
||||
|
||||
if (response.ok && contentType?.includes('application/pdf')) {
|
||||
setIsPDFContent(true);
|
||||
setResolvedPdfUrl(pdfUrl);
|
||||
} else {
|
||||
setIsPDFContent(false);
|
||||
}
|
||||
@@ -112,12 +119,45 @@ const PdfBlockComponent = ({
|
||||
};
|
||||
|
||||
void validatePDFContent();
|
||||
}, [pdfUrl]);
|
||||
}, [pdfUrl, isEncrypted]);
|
||||
|
||||
const handleDecryptPdf = useCallback(async () => {
|
||||
if (!pdfUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPDFContentLoading(true);
|
||||
try {
|
||||
const blobUrl = await decryptFileUrl(pdfUrl);
|
||||
setResolvedPdfUrl(blobUrl);
|
||||
setIsPDFContent(true);
|
||||
} catch {
|
||||
setIsPDFContent(false);
|
||||
} finally {
|
||||
setIsPDFContentLoading(false);
|
||||
}
|
||||
}, [pdfUrl, decryptFileUrl]);
|
||||
|
||||
const showEncryptedPlaceholder =
|
||||
isEncrypted &&
|
||||
isPDFContent === null &&
|
||||
pdfUrl &&
|
||||
!pdfUrl.includes(ANALYZE_URL);
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
{isPDFContentLoading && <Loading />}
|
||||
{!isEncrypted && isPDFContentLoading && <Loading />}
|
||||
{showEncryptedPlaceholder && (
|
||||
<EncryptedMediaPlaceholder
|
||||
label={t('Click to decrypt and view PDF')}
|
||||
errorLabel={t('Invalid or missing PDF file.')}
|
||||
minHeight="300px"
|
||||
isLoading={isPDFContentLoading}
|
||||
hasError={false}
|
||||
onDecrypt={() => void handleDecryptPdf()}
|
||||
/>
|
||||
)}
|
||||
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
|
||||
<Box
|
||||
$align="center"
|
||||
@@ -142,7 +182,7 @@ const PdfBlockComponent = ({
|
||||
block={block as unknown as FileBlockBlock}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
>
|
||||
{!isPDFContentLoading && isPDFContent && (
|
||||
{!isPDFContentLoading && isPDFContent && resolvedPdfUrl && (
|
||||
<Box
|
||||
as="embed"
|
||||
className="bn-visual-media"
|
||||
@@ -150,7 +190,7 @@ const PdfBlockComponent = ({
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
src={resolvedPdfUrl}
|
||||
aria-label={block.props.name || t('PDF document')}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
createVideoBlockConfig,
|
||||
videoParse,
|
||||
} from '@blocknote/core';
|
||||
import {
|
||||
ResizableFileBlockWrapper,
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
|
||||
import { useDecryptMedia } from '../../hook';
|
||||
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
|
||||
|
||||
type VideoBlockConfig = ReturnType<typeof createVideoBlockConfig>;
|
||||
|
||||
interface VideoBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'video', VideoBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'video', VideoBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const VideoBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
...rest
|
||||
}: VideoBlockComponentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
showPlaceholder,
|
||||
showMedia,
|
||||
isLoading,
|
||||
hasError,
|
||||
decrypt,
|
||||
resolvedUrl,
|
||||
} = useDecryptMedia(block.props.url);
|
||||
|
||||
return (
|
||||
<ResizableFileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
buttonIcon={
|
||||
<Icon iconName="videocam" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
>
|
||||
{showPlaceholder && (
|
||||
<EncryptedMediaPlaceholder
|
||||
label={t('Click to decrypt and play video')}
|
||||
errorLabel={t('Failed to decrypt video file.')}
|
||||
minHeight="300px"
|
||||
isLoading={isLoading}
|
||||
hasError={hasError}
|
||||
onDecrypt={() => void decrypt()}
|
||||
/>
|
||||
)}
|
||||
{showMedia && (
|
||||
<video
|
||||
className="bn-visual-media"
|
||||
src={resolvedUrl || block.props.url}
|
||||
controls
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</ResizableFileBlockWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoToExternalHTML = ({
|
||||
block,
|
||||
}: {
|
||||
block: BlockNoDefaults<
|
||||
Record<'video', VideoBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}) => {
|
||||
if (!block.props.url) {
|
||||
return <p>Add video</p>;
|
||||
}
|
||||
|
||||
return <video src={block.props.url} controls />;
|
||||
};
|
||||
|
||||
export const VideoBlock = createReactBlockSpec(
|
||||
createVideoBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['video/*'],
|
||||
},
|
||||
render: (props) => <VideoBlockComponent {...(props as any)} />,
|
||||
parse: videoParse(config),
|
||||
toExternalHTML: (props) => <VideoToExternalHTML {...(props as any)} />,
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
+2
@@ -1,4 +1,6 @@
|
||||
export * from './AccessibleImageBlock';
|
||||
export * from './AudioBlock';
|
||||
export * from './CalloutBlock';
|
||||
export * from './PdfBlock';
|
||||
export * from './UploadLoaderBlock';
|
||||
export * from './VideoBlock';
|
||||
|
||||
+4
-4
@@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
|
||||
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('useSaveDoc', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('useSaveDoc', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('useSaveDoc', () => {
|
||||
const docId = 'test-doc-id';
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useDecryptMedia';
|
||||
export * from './useHeadings';
|
||||
export * from './useSaveDoc';
|
||||
export * from './useShortcuts';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useEncryption } from '../components/EncryptionProvider';
|
||||
import { ANALYZE_URL } from '../conf';
|
||||
|
||||
export const useDecryptMedia = (url: string | undefined) => {
|
||||
const {
|
||||
isEncrypted,
|
||||
decryptFileUrl,
|
||||
revealAllCounter,
|
||||
registerPlaceholder,
|
||||
unregisterPlaceholder,
|
||||
} = useEncryption();
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const isAnalyzing = !!url && url.includes(ANALYZE_URL);
|
||||
|
||||
const decrypt = useCallback(async () => {
|
||||
if (!url || resolvedUrl || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
try {
|
||||
const blobUrl = await decryptFileUrl(url);
|
||||
setResolvedUrl(blobUrl);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [url, resolvedUrl, isLoading, decryptFileUrl]);
|
||||
|
||||
// Auto-decrypt when "Reveal all" is requested
|
||||
const decryptRef = useRef(decrypt);
|
||||
decryptRef.current = decrypt;
|
||||
|
||||
useEffect(() => {
|
||||
if (revealAllCounter > 0 && isEncrypted && url && !isAnalyzing) {
|
||||
void decryptRef.current();
|
||||
}
|
||||
}, [revealAllCounter, isEncrypted, url, isAnalyzing]);
|
||||
|
||||
const showPlaceholder =
|
||||
isEncrypted && !resolvedUrl && !hasError && !!url && !isAnalyzing;
|
||||
|
||||
const showMedia = !!url && !isAnalyzing && (!isEncrypted || !!resolvedUrl);
|
||||
|
||||
// Track pending placeholders in the provider
|
||||
const wasShowingPlaceholder = useRef(false);
|
||||
useEffect(() => {
|
||||
if (showPlaceholder && !wasShowingPlaceholder.current) {
|
||||
registerPlaceholder();
|
||||
wasShowingPlaceholder.current = true;
|
||||
} else if (!showPlaceholder && wasShowingPlaceholder.current) {
|
||||
unregisterPlaceholder();
|
||||
wasShowingPlaceholder.current = false;
|
||||
}
|
||||
}, [showPlaceholder, registerPlaceholder, unregisterPlaceholder]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wasShowingPlaceholder.current) {
|
||||
unregisterPlaceholder();
|
||||
}
|
||||
};
|
||||
}, [unregisterPlaceholder]);
|
||||
|
||||
return {
|
||||
isEncrypted,
|
||||
resolvedUrl,
|
||||
isLoading,
|
||||
hasError,
|
||||
decrypt,
|
||||
showPlaceholder,
|
||||
showMedia,
|
||||
};
|
||||
};
|
||||
@@ -2,8 +2,10 @@ import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { useUpdateDoc, useProviderStore } from '@/docs/doc-management/';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
import { toBase64 } from '../utils';
|
||||
@@ -14,7 +16,11 @@ export const useSaveDoc = (
|
||||
docId: string,
|
||||
yDoc: Y.Doc,
|
||||
isConnectedToCollabServer: boolean,
|
||||
isEncrypted: boolean,
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null,
|
||||
) => {
|
||||
const { encryptionTransition } = useProviderStore();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
onSuccess: () => {
|
||||
@@ -23,11 +29,6 @@ export const useSaveDoc = (
|
||||
});
|
||||
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Update initial doc when doc is updated by other users,
|
||||
* so only the user typing will trigger the save.
|
||||
* This is to avoid saving the same doc multiple time.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onUpdate = (
|
||||
_uintArray: Uint8Array,
|
||||
@@ -48,16 +49,53 @@ export const useSaveDoc = (
|
||||
const saveDoc = useCallback(() => {
|
||||
if (!isLocalChange) {
|
||||
return false;
|
||||
} else if (encryptionTransition) {
|
||||
return false;
|
||||
} else if (isEncrypted && (!documentEncryptionSettings || !vaultClient)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
|
||||
websocket: isConnectedToCollabServer,
|
||||
});
|
||||
const state = Y.encodeStateAsUpdate(yDoc);
|
||||
|
||||
if (isEncrypted && documentEncryptionSettings && vaultClient) {
|
||||
// Encrypt via vault with ArrayBuffer — zero-copy
|
||||
vaultClient
|
||||
.encryptWithKey(
|
||||
state.buffer as ArrayBuffer,
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
)
|
||||
.then(({ encryptedData }) => {
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: toBase64(new Uint8Array(encryptedData)),
|
||||
contentEncrypted: true,
|
||||
websocket: isConnectedToCollabServer,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to encrypt document for save:', err);
|
||||
});
|
||||
} else {
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: toBase64(state),
|
||||
contentEncrypted: false,
|
||||
websocket: isConnectedToCollabServer,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
|
||||
}, [
|
||||
isLocalChange,
|
||||
encryptionTransition,
|
||||
updateDoc,
|
||||
docId,
|
||||
yDoc,
|
||||
isConnectedToCollabServer,
|
||||
isEncrypted,
|
||||
documentEncryptionSettings,
|
||||
vaultClient,
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -65,13 +103,6 @@ export const useSaveDoc = (
|
||||
const onSave = (e?: Event) => {
|
||||
const isSaving = saveDoc();
|
||||
|
||||
/**
|
||||
* Firefox does not trigger the request every time the user leaves the page.
|
||||
* Plus the request is not intercepted by the service worker.
|
||||
* So we prevent the default behavior to have the popup asking the user
|
||||
* if he wants to leave the page, by adding the popup, we let the time to the
|
||||
* request to be sent, and intercepted by the service worker (for the offline part).
|
||||
*/
|
||||
if (
|
||||
isSaving &&
|
||||
typeof e !== 'undefined' &&
|
||||
@@ -82,16 +113,12 @@ export const useSaveDoc = (
|
||||
}
|
||||
};
|
||||
|
||||
// Save every minute
|
||||
const timeout = setInterval(onSave, SAVE_INTERVAL);
|
||||
// Save when the user leaves the page
|
||||
addEventListener('beforeunload', onSave);
|
||||
// Save when the user navigates to another page
|
||||
router.events.on('routeChangeStart', onSave);
|
||||
|
||||
return () => {
|
||||
clearInterval(timeout);
|
||||
|
||||
removeEventListener('beforeunload', onSave);
|
||||
router.events.off('routeChangeStart', onSave);
|
||||
};
|
||||
|
||||
@@ -4,22 +4,43 @@ import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { backendUrl } from '@/api';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
import { useCreateDocAttachment } from '../api';
|
||||
import { ANALYZE_URL } from '../conf';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
export const useUploadFile = (docId: string) => {
|
||||
export const useUploadFile = (
|
||||
docId: string,
|
||||
encryptedSymmetricKey?: ArrayBuffer,
|
||||
) => {
|
||||
const {
|
||||
mutateAsync: createDocAttachment,
|
||||
isError: isErrorAttachment,
|
||||
error: errorAttachment,
|
||||
} = useCreateDocAttachment();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
if (encryptedSymmetricKey && vaultClient) {
|
||||
// Encrypt the file via vault — pure ArrayBuffer
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const { encryptedData } = await vaultClient.encryptWithKey(
|
||||
fileBuffer,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const encryptedFile = new File([encryptedData], file.name, {
|
||||
type: 'application/octet-stream',
|
||||
});
|
||||
body.append('file', encryptedFile);
|
||||
body.append('is_encrypted', 'true');
|
||||
} else {
|
||||
body.append('file', file);
|
||||
}
|
||||
|
||||
const ret = await createDocAttachment({
|
||||
docId,
|
||||
@@ -28,7 +49,7 @@ export const useUploadFile = (docId: string) => {
|
||||
|
||||
return `${backendUrl()}${ret.file}`;
|
||||
},
|
||||
[createDocAttachment, docId],
|
||||
[createDocAttachment, docId, encryptedSymmetricKey, vaultClient],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -42,16 +63,10 @@ export const useUploadFile = (docId: string) => {
|
||||
* When we upload a file it can takes some time to analyze it (e.g. virus scan).
|
||||
* This hook listen to upload end and replace the uploaded block by a uploadLoader
|
||||
* block to show analyzing status.
|
||||
* The uploadLoader block will then handle the status display until the analysis is done
|
||||
* then replaced by the final block (e.g. image, pdf, etc.).
|
||||
* @param editor
|
||||
*/
|
||||
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Replace the resource block by a uploadLoader block to show analyzing status
|
||||
*/
|
||||
const replaceBlockWithUploadLoader = useCallback(
|
||||
(block: Block) => {
|
||||
if (
|
||||
@@ -95,7 +110,6 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted before accessing document
|
||||
if (!editor?.document) {
|
||||
return;
|
||||
}
|
||||
@@ -110,12 +124,7 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
});
|
||||
}, [editor, replaceBlockWithUploadLoader]);
|
||||
|
||||
/**
|
||||
* Handle upload end to replace the upload block by a uploadLoader
|
||||
* block to show analyzing status
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted before setting up handlers
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Doc } from '@/docs/doc-management';
|
||||
interface BoutonShareProps {
|
||||
displayNbAccess: boolean;
|
||||
doc: Doc;
|
||||
hasKeyWarning?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
open: () => void;
|
||||
@@ -18,6 +19,7 @@ interface BoutonShareProps {
|
||||
export const BoutonShare = ({
|
||||
displayNbAccess,
|
||||
doc,
|
||||
hasKeyWarning,
|
||||
isDisabled,
|
||||
isHidden,
|
||||
open,
|
||||
@@ -44,6 +46,9 @@ export const BoutonShare = ({
|
||||
if (hasAccesses) {
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="4px"
|
||||
$css={css`
|
||||
.c__button--medium {
|
||||
height: var(--c--globals--spacings--lg);
|
||||
@@ -55,9 +60,10 @@ export const BoutonShare = ({
|
||||
<Button
|
||||
aria-label={t('Share button')}
|
||||
variant="secondary"
|
||||
color={hasKeyWarning ? 'warning' : undefined}
|
||||
icon={
|
||||
<Icon
|
||||
iconName="group"
|
||||
iconName={hasKeyWarning ? 'warning' : 'group'}
|
||||
$color="inherit"
|
||||
variant="filled"
|
||||
disabled={isDisabled}
|
||||
@@ -75,8 +81,11 @@ export const BoutonShare = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="brand"
|
||||
color={hasKeyWarning ? 'warning' : 'brand'}
|
||||
variant="tertiary"
|
||||
icon={
|
||||
hasKeyWarning ? <Icon iconName="warning" $color="inherit" /> : undefined
|
||||
}
|
||||
onClick={open}
|
||||
size="medium"
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, HorizontalSeparator } from '@/components';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
@@ -20,9 +21,13 @@ import { DocToolBox } from './DocToolBox';
|
||||
|
||||
interface DocHeaderProps {
|
||||
doc: Doc;
|
||||
documentEncryptionSettings?: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
export const DocHeader = ({
|
||||
doc,
|
||||
documentEncryptionSettings,
|
||||
}: DocHeaderProps) => {
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -65,7 +70,12 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
<DocHeaderInfo doc={doc} />
|
||||
</Box>
|
||||
</Box>
|
||||
{!isDeletedDoc && <DocToolBox doc={doc} />}
|
||||
{!isDeletedDoc && (
|
||||
<DocToolBox
|
||||
doc={doc}
|
||||
documentEncryptionSettings={documentEncryptionSettings}
|
||||
/>
|
||||
)}
|
||||
{isDeletedDoc && (
|
||||
<BoutonShare
|
||||
doc={doc}
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
KEY_LIST_FAVORITE_DOC,
|
||||
ModalEncryptDoc,
|
||||
ModalRemoveDoc,
|
||||
ModalRemoveDocEncryption,
|
||||
getEmojiAndTitle,
|
||||
useCopyDocLink,
|
||||
useCreateFavoriteDoc,
|
||||
@@ -29,6 +31,11 @@ import {
|
||||
useDocUtils,
|
||||
useDuplicateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import {
|
||||
usePublicKeyRegistry,
|
||||
useUserEncryption,
|
||||
} from '@/docs/doc-collaboration';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { DocShareModal } from '@/docs/doc-share';
|
||||
import {
|
||||
KEY_LIST_DOC_VERSIONS,
|
||||
@@ -44,9 +51,13 @@ const ModalExport = Export?.ModalExport;
|
||||
|
||||
interface DocToolBoxProps {
|
||||
doc: Doc;
|
||||
documentEncryptionSettings?: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
export const DocToolBox = ({
|
||||
doc,
|
||||
documentEncryptionSettings,
|
||||
}: DocToolBoxProps) => {
|
||||
const { t } = useTranslation();
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -54,12 +65,21 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { isChild, isTopRoot } = useDocUtils(doc);
|
||||
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { encryptionSettings } = useUserEncryption();
|
||||
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
|
||||
const [isModalEncryptOpen, setIsModalEncryptOpen] = useState(false);
|
||||
const [isModalRemoveEncryptionOpen, setIsModalRemoveEncryptionOpen] =
|
||||
useState(false);
|
||||
const selectHistoryModal = useModal();
|
||||
const modalShare = useModal();
|
||||
|
||||
const { hasMismatches: hasKeyWarnings } = usePublicKeyRegistry(
|
||||
undefined,
|
||||
encryptionSettings?.userId,
|
||||
);
|
||||
|
||||
const { isSmallMobile, isMobile } = useResponsiveStore();
|
||||
const copyDocLink = useCopyDocLink(doc.id);
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||
@@ -125,6 +145,26 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
show: !isMobile,
|
||||
showSeparator: isTopRoot ? true : false,
|
||||
},
|
||||
{
|
||||
label: t('Encrypt document'),
|
||||
icon: 'https',
|
||||
disabled: !doc.abilities.accesses_manage,
|
||||
callback: () => {
|
||||
setIsModalEncryptOpen(true);
|
||||
},
|
||||
show: !doc.is_encrypted && doc.abilities.update,
|
||||
showSeparator: isTopRoot ? true : false,
|
||||
},
|
||||
{
|
||||
label: t('Remove document encryption'),
|
||||
icon: 'no_encryption',
|
||||
disabled: !doc.abilities.accesses_manage,
|
||||
callback: () => {
|
||||
setIsModalRemoveEncryptionOpen(true);
|
||||
},
|
||||
show: doc.is_encrypted && doc.abilities.update,
|
||||
showSeparator: isTopRoot ? true : false,
|
||||
},
|
||||
{
|
||||
label: t('Remove emoji'),
|
||||
icon: 'emoji_emotions',
|
||||
@@ -196,11 +236,34 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
$margin={{ left: 'auto' }}
|
||||
$gap={spacingsTokens['2xs']}
|
||||
>
|
||||
{doc.is_encrypted && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="5px"
|
||||
$css={css`
|
||||
margin-right: 5px;
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
iconName="lock"
|
||||
$size="md"
|
||||
$color={colorsTokens['success-600']}
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: '0.8rem', color: colorsTokens['success-600'] }}
|
||||
>
|
||||
{t('Encrypted')}
|
||||
</span>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BoutonShare
|
||||
doc={doc}
|
||||
open={modalShare.open}
|
||||
isHidden={isSmallMobile}
|
||||
displayNbAccess={doc.abilities.accesses_view}
|
||||
hasKeyWarning={hasKeyWarnings}
|
||||
/>
|
||||
|
||||
{!isSmallMobile && ModalExport && (
|
||||
@@ -217,6 +280,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
aria-label={t('Export the document')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
label={t('Open the document options')}
|
||||
@@ -237,6 +301,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
<DocShareModal
|
||||
onClose={() => modalShare.close()}
|
||||
doc={doc}
|
||||
documentEncryptionSettings={documentEncryptionSettings}
|
||||
isRootDoc={treeContext?.root?.id === doc.id}
|
||||
/>
|
||||
)}
|
||||
@@ -265,6 +330,20 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isModalEncryptOpen && (
|
||||
<ModalEncryptDoc
|
||||
doc={doc}
|
||||
onClose={() => setIsModalEncryptOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isModalRemoveEncryptionOpen &&
|
||||
documentEncryptionSettings?.encryptedSymmetricKey && (
|
||||
<ModalRemoveDocEncryption
|
||||
doc={doc}
|
||||
encryptedSymmetricKey={documentEncryptionSettings.encryptedSymmetricKey}
|
||||
onClose={() => setIsModalRemoveEncryptionOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{selectHistoryModal.isOpen && (
|
||||
<ModalSelectVersion
|
||||
onClose={() => selectHistoryModal.close()}
|
||||
|
||||
@@ -7,6 +7,8 @@ export * from './useDocOptions';
|
||||
export * from './useDocs';
|
||||
export * from './useDocsFavorite';
|
||||
export * from './useDuplicateDoc';
|
||||
export * from './useEncryptDoc';
|
||||
export * from './useRemoveDocEncryption';
|
||||
export * from './useRestoreDoc';
|
||||
export * from './useSubDocs';
|
||||
export * from './useUpdateDoc';
|
||||
|
||||
@@ -8,16 +8,19 @@ import { KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
export type CreateChildDocParam = Pick<Doc, 'title'> & {
|
||||
parentId: string;
|
||||
isEncrypted: boolean;
|
||||
};
|
||||
|
||||
export const createChildDoc = async ({
|
||||
title,
|
||||
parentId,
|
||||
isEncrypted = false,
|
||||
}: CreateChildDocParam): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/${parentId}/children/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
is_encrypted: isEncrypted,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -12,12 +12,16 @@ import { KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
type CreateDocParams = {
|
||||
title?: string;
|
||||
isEncrypted?: boolean;
|
||||
} | void;
|
||||
|
||||
export const createDoc = async (params: CreateDocParams): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: params?.title }),
|
||||
body: JSON.stringify({
|
||||
title: params?.title,
|
||||
is_encrypted: params?.isEncrypted ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -17,6 +17,7 @@ export type DocsParams = {
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
is_encrypted?: boolean;
|
||||
};
|
||||
|
||||
export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
@@ -37,6 +38,9 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
if (params.is_favorite !== undefined) {
|
||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||
}
|
||||
if (params.is_encrypted !== undefined) {
|
||||
searchParams.set('is_encrypted', params.is_encrypted.toString());
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
};
|
||||
|
||||
@@ -75,10 +75,15 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
|
||||
provider.document.guid === variables.docId;
|
||||
|
||||
if (canSave) {
|
||||
await updateDoc({
|
||||
id: variables.docId,
|
||||
content: toBase64(Y.encodeStateAsUpdate(provider.document)),
|
||||
});
|
||||
const state = Y.encodeStateAsUpdate(provider.document);
|
||||
|
||||
if (state) {
|
||||
await updateDoc({
|
||||
id: variables.docId,
|
||||
content: toBase64(state),
|
||||
contentEncrypted: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await duplicateDoc(variables);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
|
||||
interface EncryptDocProps {
|
||||
docId: string;
|
||||
content: Uint8Array<ArrayBufferLike>;
|
||||
/** Per-user encrypted symmetric keys as base64 strings (from VaultClient.encryptForUsers) */
|
||||
encryptedSymmetricKeyPerUser: Record<string, string>;
|
||||
attachmentKeyMapping?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const encryptDoc = async ({
|
||||
docId,
|
||||
...params
|
||||
}: EncryptDocProps): Promise<void> => {
|
||||
const response = await fetchAPI(`documents/${docId}/encrypt/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
content: toBase64(params.content),
|
||||
encryptedSymmetricKeyPerUser: params.encryptedSymmetricKeyPerUser,
|
||||
attachmentKeyMapping: params.attachmentKeyMapping || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to encrypt the doc',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseEncryptDocOptions = UseMutationOptions<void, APIError, EncryptDocProps>;
|
||||
|
||||
export const useEncryptDoc = ({
|
||||
listInvalidQueries,
|
||||
options,
|
||||
}: {
|
||||
listInvalidQueries?: string[];
|
||||
options?: UseEncryptDocOptions;
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, APIError, EncryptDocProps>({
|
||||
mutationFn: encryptDoc,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
|
||||
interface RemoveDocEncryptionProps {
|
||||
docId: string;
|
||||
content: Uint8Array<ArrayBufferLike>;
|
||||
attachmentKeyMapping?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const removeDocEncryption = async ({
|
||||
docId,
|
||||
...params
|
||||
}: RemoveDocEncryptionProps): Promise<void> => {
|
||||
const response = await fetchAPI(`documents/${docId}/remove-encryption/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
content: toBase64(params.content),
|
||||
attachmentKeyMapping: params.attachmentKeyMapping || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to remove encryption from the doc',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseRemoveDocEncryptionOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
RemoveDocEncryptionProps
|
||||
>;
|
||||
|
||||
export const useRemoveDocEncryption = ({
|
||||
listInvalidQueries,
|
||||
options,
|
||||
}: {
|
||||
listInvalidQueries?: string[];
|
||||
options?: UseRemoveDocEncryptionOptions;
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, APIError, RemoveDocEncryptionProps>({
|
||||
mutationFn: removeDocEncryption,
|
||||
...options,
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -18,6 +18,7 @@ export type SubDocsParams = {
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
is_encrypted?: boolean;
|
||||
parent_id: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { KEY_CAN_EDIT } from './useDocCanEdit';
|
||||
|
||||
export type UpdateDocParams = Pick<Doc, 'id'> &
|
||||
Partial<Pick<Doc, 'content' | 'title'>> & {
|
||||
contentEncrypted?: boolean;
|
||||
websocket?: boolean;
|
||||
};
|
||||
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
<svg
|
||||
width="33"
|
||||
height="33"
|
||||
viewBox="0 0 33 33"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_3236_2932)">
|
||||
<g clip-path="url(#clip1_3236_2932)">
|
||||
<rect x="4.5" y="0.5" width="24" height="32" rx="3.55556" fill="white" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 7.7623C8.08374 7.27138 8.48171 6.87341 8.97263 6.87341H16.9726C17.4635 6.87341 17.8615 7.27138 17.8615 7.7623C17.8615 8.25322 17.4635 8.65118 16.9726 8.65118H8.97263C8.48171 8.65118 8.08374 8.25322 8.08374 7.7623Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 10.7685C8.08374 10.2776 8.48171 9.87964 8.97263 9.87964H24.0273C24.5182 9.87964 24.9162 10.2776 24.9162 10.7685C24.9162 11.2594 24.5182 11.6574 24.0273 11.6574H8.97263C8.48171 11.6574 8.08374 11.2594 8.08374 10.7685ZM8.08374 13.4352C8.08374 12.9443 8.48171 12.5463 8.97263 12.5463H24.0273C24.5182 12.5463 24.9162 12.9443 24.9162 13.4352C24.9162 13.9261 24.5182 14.3241 24.0273 14.3241H8.97263C8.48171 14.3241 8.08374 13.9261 8.08374 13.4352ZM8.08374 16.1019C8.08374 15.6109 8.48171 15.213 8.97263 15.213H24.0273C24.5182 15.213 24.9162 15.6109 24.9162 16.1019C24.9162 16.5928 24.5182 16.9907 24.0273 16.9907H8.97263C8.48171 16.9907 8.08374 16.5928 8.08374 16.1019ZM8.08374 18.7685C8.08374 18.2776 8.48171 17.8796 8.97263 17.8796H24.0273C24.5182 17.8796 24.9162 18.2776 24.9162 18.7685C24.9162 19.2594 24.5182 19.6574 24.0273 19.6574H8.97263C8.48171 19.6574 8.08374 19.2594 8.08374 18.7685Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 10.7685C8.08374 10.2776 8.48171 9.87964 8.97263 9.87964H24.0273C24.5182 9.87964 24.9162 10.2776 24.9162 10.7685C24.9162 11.2594 24.5182 11.6574 24.0273 11.6574H8.97263C8.48171 11.6574 8.08374 11.2594 8.08374 10.7685ZM8.08374 13.4352C8.08374 12.9443 8.48171 12.5463 8.97263 12.5463H24.0273C24.5182 12.5463 24.9162 12.9443 24.9162 13.4352C24.9162 13.9261 24.5182 14.3241 24.0273 14.3241H8.97263C8.48171 14.3241 8.08374 13.9261 8.08374 13.4352ZM8.08374 16.1019C8.08374 15.6109 8.48171 15.213 8.97263 15.213H24.0273C24.5182 15.213 24.9162 15.6109 24.9162 16.1019C24.9162 16.5928 24.5182 16.9907 24.0273 16.9907H8.97263C8.48171 16.9907 8.08374 16.5928 8.08374 16.1019ZM8.08374 18.7685C8.08374 18.2776 8.48171 17.8796 8.97263 17.8796H24.0273C24.5182 17.8796 24.9162 18.2776 24.9162 18.7685C24.9162 19.2594 24.5182 19.6574 24.0273 19.6574H8.97263C8.48171 19.6574 8.08374 19.2594 8.08374 18.7685Z"
|
||||
fill="white"
|
||||
fill-opacity="0.65"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 21.4666C8.08374 20.9757 8.48171 20.5777 8.97263 20.5777H24.0273C24.5182 20.5777 24.9162 20.9757 24.9162 21.4666C24.9162 21.9575 24.5182 22.3555 24.0273 22.3555H8.97263C8.48171 22.3555 8.08374 21.9575 8.08374 21.4666ZM8.08374 24.1333C8.08374 23.6424 8.48171 23.2444 8.97263 23.2444H24.0273C24.5182 23.2444 24.9162 23.6424 24.9162 24.1333C24.9162 24.6242 24.5182 25.0222 24.0273 25.0222H8.97263C8.48171 25.0222 8.08374 24.6242 8.08374 24.1333ZM8.08374 26.8C8.08374 26.309 8.48171 25.9111 8.97263 25.9111H24.0273C24.5182 25.9111 24.9162 26.309 24.9162 26.8C24.9162 27.2909 24.5182 27.6888 24.0273 27.6888H8.97263C8.48171 27.6888 8.08374 27.2909 8.08374 26.8ZM8.08374 29.4666C8.08374 28.9757 8.48171 28.5777 8.97263 28.5777H24.0273C24.5182 28.5777 24.9162 28.9757 24.9162 29.4666C24.9162 29.9575 24.5182 30.3555 24.0273 30.3555H8.97263C8.48171 30.3555 8.08374 29.9575 8.08374 29.4666Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.08374 21.4666C8.08374 20.9757 8.48171 20.5777 8.97263 20.5777H24.0273C24.5182 20.5777 24.9162 20.9757 24.9162 21.4666C24.9162 21.9575 24.5182 22.3555 24.0273 22.3555H8.97263C8.48171 22.3555 8.08374 21.9575 8.08374 21.4666ZM8.08374 24.1333C8.08374 23.6424 8.48171 23.2444 8.97263 23.2444H24.0273C24.5182 23.2444 24.9162 23.6424 24.9162 24.1333C24.9162 24.6242 24.5182 25.0222 24.0273 25.0222H8.97263C8.48171 25.0222 8.08374 24.6242 8.08374 24.1333ZM8.08374 26.8C8.08374 26.309 8.48171 25.9111 8.97263 25.9111H24.0273C24.5182 25.9111 24.9162 26.309 24.9162 26.8C24.9162 27.2909 24.5182 27.6888 24.0273 27.6888H8.97263C8.48171 27.6888 8.08374 27.2909 8.08374 26.8ZM8.08374 29.4666C8.08374 28.9757 8.48171 28.5777 8.97263 28.5777H24.0273C24.5182 28.5777 24.9162 28.9757 24.9162 29.4666C24.9162 29.9575 24.5182 30.3555 24.0273 30.3555H8.97263C8.48171 30.3555 8.08374 29.9575 8.08374 29.4666Z"
|
||||
fill="white"
|
||||
fill-opacity="0.65"
|
||||
/>
|
||||
<rect
|
||||
x="4.57422"
|
||||
y="0.5"
|
||||
width="23.9258"
|
||||
height="31.8206"
|
||||
fill="url(#paint0_linear_3236_2932)"
|
||||
fill-opacity="0.4"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="4.85"
|
||||
y="0.85"
|
||||
width="23.3"
|
||||
height="31.3"
|
||||
rx="3.20556"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="4.85"
|
||||
y="0.85"
|
||||
width="23.3"
|
||||
height="31.3"
|
||||
rx="3.20556"
|
||||
stroke="white"
|
||||
stroke-opacity="0.65"
|
||||
stroke-width="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="4.85"
|
||||
y="0.85"
|
||||
width="23.3"
|
||||
height="31.3"
|
||||
rx="3.20556"
|
||||
stroke="url(#paint1_linear_3236_2932)"
|
||||
stroke-opacity="0.23"
|
||||
stroke-width="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="10.0132"
|
||||
y="10.0132"
|
||||
width="12.9736"
|
||||
height="12.9736"
|
||||
rx="6.48682"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect
|
||||
x="10.0132"
|
||||
y="10.0132"
|
||||
width="12.9736"
|
||||
height="12.9736"
|
||||
rx="6.48682"
|
||||
stroke="white"
|
||||
stroke-width="1.21628"
|
||||
/>
|
||||
<g transform="translate(16.5 16.5) scale(0.27)">
|
||||
<path transform="translate(-12 -12)" d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_3236_2932"
|
||||
x1="16.5371"
|
||||
y1="0.5"
|
||||
x2="16.5371"
|
||||
y2="32.3206"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="white" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="white" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3236_2932"
|
||||
x1="16.5"
|
||||
y1="0.5"
|
||||
x2="16.5"
|
||||
y2="32.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="white" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="white" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3236_2932">
|
||||
<rect
|
||||
width="32"
|
||||
height="32"
|
||||
fill="white"
|
||||
transform="translate(0.5 0.5)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3236_2932">
|
||||
<rect x="4.5" y="0.5" width="24" height="32" rx="3.55556" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
+467
@@ -0,0 +1,467 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, ButtonCloseModal, Icon, Text, TextErrors } from '@/components';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
import { useUserEncryption } from '@/docs/doc-collaboration';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { createDocAttachment } from '@/docs/doc-editor/api';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import {
|
||||
Doc,
|
||||
EncryptionTransitionEvent,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
LinkReach,
|
||||
extractAttachmentKeysAndMetadata,
|
||||
getDocLinkReach,
|
||||
useEncryptDoc,
|
||||
useProviderStore,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { useDocAccesses } from '@/features/docs/doc-share/api/useDocAccesses';
|
||||
import { useDocInvitations } from '@/features/docs/doc-share/api/useDocInvitations';
|
||||
import { useKeyboardAction } from '@/hooks';
|
||||
import { Spinner } from '@gouvfr-lasuite/ui-kit';
|
||||
|
||||
/**
|
||||
* encrypt existing unencrypted attachments and return:
|
||||
* - a modified Yjs state with URLs pointing to new encrypted files
|
||||
* - a mapping of old S3 keys to new ones (for backend cleanup)
|
||||
*
|
||||
* originals are never modified so if the process fails midway the document
|
||||
* still works with its original unencrypted attachments.
|
||||
*/
|
||||
const encryptRemoteAttachments = async (
|
||||
yDoc: Y.Doc,
|
||||
docId: string,
|
||||
vaultClient: VaultClient,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
): Promise<Record<string, string>> => {
|
||||
const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc);
|
||||
|
||||
// if no attachment it's straightforward
|
||||
if (attachmentKeysAndMetadata.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// otherwise upload encrypted copies as new attachments and collect the mapping
|
||||
const attachmentKeyMapping: Record<string, string> = {};
|
||||
|
||||
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
|
||||
attachmentKeysAndMetadata.entries(),
|
||||
)) {
|
||||
const response = await fetch(oldAttachmentMetadata.mediaUrl, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('attachment cannot be fetch');
|
||||
}
|
||||
|
||||
// Encrypt file via vault — pure ArrayBuffer, no base64 conversion
|
||||
const fileBuffer = await response.arrayBuffer();
|
||||
const { encryptedData: encryptedBuffer } = await vaultClient.encryptWithKey(
|
||||
fileBuffer,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
const encryptedBytes = new Uint8Array(encryptedBuffer);
|
||||
|
||||
const fileName = oldAttachmentMetadata.name ?? 'file'; // since encrypted we could not reuse the file name that can be stored as clear text
|
||||
const encryptedFile = new File([encryptedBytes], fileName, {
|
||||
type: 'application/octet-stream',
|
||||
});
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', encryptedFile);
|
||||
body.append('is_encrypted', 'true');
|
||||
|
||||
const result = await createDocAttachment({ docId, body });
|
||||
|
||||
// result.file is like "/api/v1.0/documents/{id}/media-check/?key={newKey}"
|
||||
const newKey = new URL(
|
||||
result.file,
|
||||
window.location.origin,
|
||||
).searchParams.get('key');
|
||||
|
||||
if (!newKey) {
|
||||
throw new Error('file key must be provided once uploaded');
|
||||
}
|
||||
|
||||
attachmentKeyMapping[oldAttachmentKey] = newKey;
|
||||
}
|
||||
|
||||
// once uploaded, we can update all nodes referencing attachments with their new key
|
||||
yDoc.transact(() => {
|
||||
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
|
||||
attachmentKeysAndMetadata.entries(),
|
||||
)) {
|
||||
const newMediaUrl = oldAttachmentMetadata.mediaUrl.replace(
|
||||
oldAttachmentKey,
|
||||
attachmentKeyMapping[oldAttachmentKey],
|
||||
);
|
||||
|
||||
for (const node of oldAttachmentMetadata.nodes) {
|
||||
node.setAttribute('url', newMediaUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return attachmentKeyMapping;
|
||||
};
|
||||
|
||||
interface ModalEncryptDocProps {
|
||||
doc: Doc;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { provider, notifyOthers, startEncryptionTransition } =
|
||||
useProviderStore();
|
||||
const { user } = useAuth();
|
||||
const { encryptionSettings } = useUserEncryption();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: encryptDoc,
|
||||
isError,
|
||||
error,
|
||||
} = useEncryptDoc({
|
||||
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
});
|
||||
|
||||
const { data: invitationsData } = useDocInvitations({
|
||||
docId: doc.id,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const { data: accesses } = useDocAccesses({ docId: doc.id });
|
||||
|
||||
const keyboardAction = useKeyboardAction();
|
||||
|
||||
const effectiveReach = getDocLinkReach(doc);
|
||||
const isRestricted = effectiveReach === LinkReach.RESTRICTED;
|
||||
const hasPendingInvitations = !!invitationsData && invitationsData.count > 0;
|
||||
|
||||
// Fetch public keys from the encryption service to check who has encryption enabled
|
||||
const [publicKeysMap, setPublicKeysMap] = useState<Record<string, ArrayBuffer>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!accesses || !vaultClient) return;
|
||||
|
||||
const userIds = accesses
|
||||
.filter((a) => a.user?.suite_user_id)
|
||||
.map((a) => a.user.suite_user_id!);
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
vaultClient.fetchPublicKeys(userIds)
|
||||
.then(({ publicKeys }) => setPublicKeysMap(publicKeys))
|
||||
.catch(() => {});
|
||||
}, [accesses, vaultClient]);
|
||||
|
||||
const membersWithoutKey = useMemo(() => {
|
||||
if (!accesses) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return accesses.filter(
|
||||
(access) => access.user?.suite_user_id && !publicKeysMap[access.user.suite_user_id],
|
||||
);
|
||||
}, [accesses, publicKeysMap]);
|
||||
|
||||
const hasEncryptionKeys = !!encryptionSettings;
|
||||
|
||||
const canEncrypt =
|
||||
hasEncryptionKeys &&
|
||||
isRestricted &&
|
||||
!hasPendingInvitations &&
|
||||
membersWithoutKey.length === 0;
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEncrypt = async () => {
|
||||
if (!provider || !user || isPending || !canEncrypt || !encryptionSettings || !vaultClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED);
|
||||
|
||||
if (Object.keys(publicKeysMap).length === 0) {
|
||||
throw new Error('No public keys available. All members must have encryption enabled.');
|
||||
}
|
||||
|
||||
// Clone the Yjs document for encryption
|
||||
const ongoingDoc = new Y.Doc();
|
||||
Y.applyUpdate(ongoingDoc, Y.encodeStateAsUpdate(provider.document));
|
||||
|
||||
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
|
||||
|
||||
// Encrypt document content via vault — pure ArrayBuffer
|
||||
const { encryptedContent: encryptedContentBuffer, encryptedKeys } =
|
||||
await vaultClient.encryptForUsers(
|
||||
ongoingDocState.buffer as ArrayBuffer,
|
||||
publicKeysMap,
|
||||
);
|
||||
|
||||
// Convert ArrayBuffer encrypted keys to base64 for the backend API
|
||||
const encryptedSymmetricKeyPerUser: Record<string, string> = {};
|
||||
|
||||
for (const [uid, keyBuffer] of Object.entries(encryptedKeys)) {
|
||||
encryptedSymmetricKeyPerUser[uid] = toBase64(new Uint8Array(keyBuffer));
|
||||
}
|
||||
|
||||
// Get the current user's encrypted key for attachment encryption
|
||||
const currentUserEncryptedKey = user.suite_user_id ? encryptedKeys[user.suite_user_id] : undefined;
|
||||
|
||||
// Encrypt existing attachments using the same symmetric key via vault
|
||||
let attachmentKeyMapping: Record<string, string> = {};
|
||||
|
||||
if (currentUserEncryptedKey) {
|
||||
attachmentKeyMapping = await encryptRemoteAttachments(
|
||||
ongoingDoc,
|
||||
doc.id,
|
||||
vaultClient,
|
||||
currentUserEncryptedKey,
|
||||
);
|
||||
}
|
||||
|
||||
ongoingDoc.destroy();
|
||||
|
||||
const encryptedContent = new Uint8Array(encryptedContentBuffer);
|
||||
|
||||
await encryptDoc({
|
||||
docId: doc.id,
|
||||
content: encryptedContent,
|
||||
encryptedSymmetricKeyPerUser,
|
||||
attachmentKeyMapping,
|
||||
});
|
||||
|
||||
toast(t('The document has been encrypted.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
// notify other users before destroying the provider since websocket connection needed
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED);
|
||||
|
||||
// trigger the provider switch (hocuspocus → relay)
|
||||
startEncryptionTransition('encrypting');
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_CANCELED);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseKeyDown = keyboardAction(handleClose);
|
||||
const handleEncryptKeyDown = keyboardAction(handleEncrypt);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside={!isPending}
|
||||
hideCloseButton
|
||||
onClose={handleClose}
|
||||
aria-describedby="modal-encrypt-doc-title"
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={handleClose}
|
||||
onKeyDown={handleCloseKeyDown}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
fullWidth
|
||||
onClick={handleEncrypt}
|
||||
onKeyDown={handleEncryptKeyDown}
|
||||
disabled={isPending || !canEncrypt}
|
||||
icon={
|
||||
isPending ? (
|
||||
<div>
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$align="center"
|
||||
$width="100%"
|
||||
>
|
||||
<Text
|
||||
$size="h6"
|
||||
as="h1"
|
||||
id="modal-encrypt-doc-title"
|
||||
$margin="0"
|
||||
$align="flex-start"
|
||||
>
|
||||
{t('Encrypt document')}
|
||||
</Text>
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the encrypt modal')}
|
||||
onClick={handleClose}
|
||||
onKeyDown={handleCloseKeyDown}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box className="--docs--modal-encrypt-doc" $gap="sm">
|
||||
{!isError && (
|
||||
<Box $gap="sm">
|
||||
<Alert type={VariantType.WARNING}>
|
||||
<Box $gap="xs">
|
||||
<Text $size="sm">
|
||||
{t(
|
||||
'Encrypting a document ensures that only authorized members can read its content. Keep in mind before proceeding any access will then require its user to do the encryption onboarding, with the complication of ensuring keys backups.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<Text $size="sm" $variation="secondary">
|
||||
{t('Here the conditions that must be met:')}
|
||||
</Text>
|
||||
|
||||
<Box $gap="xs">
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={hasEncryptionKeys ? 'check_circle' : 'cancel'}
|
||||
$size="sm"
|
||||
$theme={hasEncryptionKeys ? 'success' : 'error'}
|
||||
/>
|
||||
<Text
|
||||
$size="sm"
|
||||
$weight={hasEncryptionKeys ? '400' : '600'}
|
||||
$theme={hasEncryptionKeys ? undefined : 'error'}
|
||||
>
|
||||
{hasEncryptionKeys
|
||||
? t('Encryption is enabled on your account')
|
||||
: t(
|
||||
'You must enable encryption from your account menu first',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={isRestricted ? 'check_circle' : 'cancel'}
|
||||
$size="sm"
|
||||
$theme={isRestricted ? 'success' : 'error'}
|
||||
/>
|
||||
<Text
|
||||
$size="sm"
|
||||
$weight={isRestricted ? '400' : '600'}
|
||||
$theme={isRestricted ? undefined : 'error'}
|
||||
>
|
||||
{isRestricted
|
||||
? t('Document access is private')
|
||||
: t(
|
||||
'Document must be set to private (currently {{reach}})',
|
||||
{
|
||||
reach:
|
||||
effectiveReach === LinkReach.PUBLIC
|
||||
? t('public')
|
||||
: t('connected'),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={!hasPendingInvitations ? 'check_circle' : 'cancel'}
|
||||
$size="sm"
|
||||
$theme={!hasPendingInvitations ? 'success' : 'error'}
|
||||
/>
|
||||
<Text
|
||||
$size="sm"
|
||||
$weight={!hasPendingInvitations ? '400' : '600'}
|
||||
$theme={!hasPendingInvitations ? undefined : 'error'}
|
||||
>
|
||||
{!hasPendingInvitations
|
||||
? t('No pending invitations')
|
||||
: t('Pending invitations must be resolved first')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box $gap="3xs">
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={
|
||||
membersWithoutKey.length === 0 ? 'check_circle' : 'cancel'
|
||||
}
|
||||
$size="sm"
|
||||
$theme={
|
||||
membersWithoutKey.length === 0 ? 'success' : 'error'
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
$size="sm"
|
||||
$weight={membersWithoutKey.length === 0 ? '400' : '600'}
|
||||
$theme={
|
||||
membersWithoutKey.length === 0 ? undefined : 'error'
|
||||
}
|
||||
>
|
||||
{membersWithoutKey.length === 0
|
||||
? t('All members have encryption enabled')
|
||||
: t(
|
||||
'{{count}} member(s) have not enabled encryption yet',
|
||||
{ count: membersWithoutKey.length },
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
{membersWithoutKey.length > 0 && (
|
||||
<Box $margin={{ left: 'sm' }} $gap="3xs">
|
||||
{membersWithoutKey.map((access) => (
|
||||
<Text key={access.id} $size="xs" $variation="secondary">
|
||||
{access.user.full_name || access.user.email}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isError && <TextErrors causes={error.cause} />}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Spinner } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
|
||||
import { createDocAttachment } from '@/docs/doc-editor/api';
|
||||
import {
|
||||
Doc,
|
||||
EncryptionTransitionEvent,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
extractAttachmentKeysAndMetadata,
|
||||
useProviderStore,
|
||||
useRemoveDocEncryption,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { useKeyboardAction } from '@/hooks';
|
||||
|
||||
/**
|
||||
* Decrypt existing encrypted attachments using the vault and upload decrypted copies.
|
||||
*/
|
||||
const decryptRemoteAttachments = async (
|
||||
yDoc: Y.Doc,
|
||||
docId: string,
|
||||
vaultClient: VaultClient,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
): Promise<Record<string, string>> => {
|
||||
const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc);
|
||||
|
||||
if (attachmentKeysAndMetadata.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const attachmentKeyMapping: Record<string, string> = {};
|
||||
|
||||
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
|
||||
attachmentKeysAndMetadata.entries(),
|
||||
)) {
|
||||
const response = await fetch(oldAttachmentMetadata.mediaUrl, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('attachment cannot be fetched');
|
||||
}
|
||||
|
||||
// Decrypt via vault — pure ArrayBuffer
|
||||
const encryptedBuffer = await response.arrayBuffer();
|
||||
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
|
||||
encryptedBuffer,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const fileName = oldAttachmentMetadata.name ?? 'file';
|
||||
const decryptedFile = new File([decryptedBuffer], fileName);
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', decryptedFile);
|
||||
|
||||
const result = await createDocAttachment({ docId, body });
|
||||
|
||||
const newKey = new URL(
|
||||
result.file,
|
||||
window.location.origin,
|
||||
).searchParams.get('key');
|
||||
|
||||
if (!newKey) {
|
||||
throw new Error('file key must be provided once uploaded');
|
||||
}
|
||||
|
||||
attachmentKeyMapping[oldAttachmentKey] = newKey;
|
||||
}
|
||||
|
||||
yDoc.transact(() => {
|
||||
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
|
||||
attachmentKeysAndMetadata.entries(),
|
||||
)) {
|
||||
const newMediaUrl = oldAttachmentMetadata.mediaUrl.replace(
|
||||
oldAttachmentKey,
|
||||
attachmentKeyMapping[oldAttachmentKey],
|
||||
);
|
||||
|
||||
for (const node of oldAttachmentMetadata.nodes) {
|
||||
node.setAttribute('url', newMediaUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return attachmentKeyMapping;
|
||||
};
|
||||
|
||||
interface ModalRemoveDocEncryptionProps {
|
||||
doc: Doc;
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ModalRemoveDocEncryption = ({
|
||||
doc,
|
||||
encryptedSymmetricKey,
|
||||
onClose,
|
||||
}: ModalRemoveDocEncryptionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { provider, notifyOthers, startEncryptionTransition } =
|
||||
useProviderStore();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: removeDocEncryption,
|
||||
isError,
|
||||
error,
|
||||
} = useRemoveDocEncryption({
|
||||
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
});
|
||||
|
||||
const keyboardAction = useKeyboardAction();
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRemoveEncryption = async () => {
|
||||
if (!provider || isPending || !vaultClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED);
|
||||
|
||||
const ongoingDoc = new Y.Doc();
|
||||
Y.applyUpdate(ongoingDoc, Y.encodeStateAsUpdate(provider.document));
|
||||
|
||||
const attachmentKeyMapping = await decryptRemoteAttachments(
|
||||
ongoingDoc,
|
||||
doc.id,
|
||||
vaultClient,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
|
||||
ongoingDoc.destroy();
|
||||
|
||||
await removeDocEncryption({
|
||||
docId: doc.id,
|
||||
content: ongoingDocState,
|
||||
attachmentKeyMapping,
|
||||
});
|
||||
|
||||
toast(t('Encryption has been removed.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED);
|
||||
startEncryptionTransition('removing-encryption');
|
||||
} catch (err) {
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={handleClose}
|
||||
size={ModalSize.MEDIUM}
|
||||
rightActions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={isPending}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
onClick={() => void handleRemoveEncryption()}
|
||||
disabled={isPending}
|
||||
{...keyboardAction(() => void handleRemoveEncryption())}
|
||||
>
|
||||
{isPending ? <Spinner /> : t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
title={
|
||||
<Box $direction="row" $justify="space-between" $align="center">
|
||||
<Text as="h1" $size="h6" $align="flex-start" $margin="0">
|
||||
{t('Remove document encryption')}
|
||||
</Text>
|
||||
<ButtonCloseModal
|
||||
aria-label={t('Close the modal')}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<Box $margin={{ top: 'sm' }} $gap="sm">
|
||||
<Text $variation="secondary">
|
||||
{t(
|
||||
'This will permanently remove encryption from this document. All content will be stored in plain text.',
|
||||
)}
|
||||
</Text>
|
||||
{isError && error && <TextErrors causes={error.cause} />}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+24
-2
@@ -1,12 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useDate } from '@/hooks/useDate';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import ChildDocument from '../assets/child-document.svg';
|
||||
import EncryptedDocumentIcon from '../assets/encrypted-document.svg';
|
||||
import PinnedDocumentIcon from '../assets/pinned-document.svg';
|
||||
import SimpleFileIcon from '../assets/simple-document.svg';
|
||||
import { useDocUtils, useTrans } from '../hooks';
|
||||
@@ -25,12 +26,14 @@ const ItemTextCss = css`
|
||||
type SimpleDocItemProps = {
|
||||
doc: Doc;
|
||||
isPinned?: boolean;
|
||||
isEncrypted?: boolean;
|
||||
showAccesses?: boolean;
|
||||
};
|
||||
|
||||
export const SimpleDocItem = ({
|
||||
doc,
|
||||
isPinned = false,
|
||||
isEncrypted = false,
|
||||
showAccesses = false,
|
||||
}: SimpleDocItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -56,12 +59,19 @@ export const SimpleDocItem = ({
|
||||
$css={css`
|
||||
background-color: transparent;
|
||||
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
|
||||
position: relative;
|
||||
`}
|
||||
$padding={`${spacingsTokens['3xs']} 0`}
|
||||
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isPinned ? (
|
||||
{isEncrypted ? (
|
||||
<EncryptedDocumentIcon
|
||||
aria-hidden="true"
|
||||
data-testid="doc-encryption-icon"
|
||||
color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
/>
|
||||
) : isPinned ? (
|
||||
<PinnedDocumentIcon
|
||||
aria-hidden="true"
|
||||
data-testid="doc-pinned-icon"
|
||||
@@ -82,6 +92,18 @@ export const SimpleDocItem = ({
|
||||
color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
/>
|
||||
)}
|
||||
{isPinned && isEncrypted && (
|
||||
<Icon
|
||||
iconName="push_pin"
|
||||
$size="12px"
|
||||
$color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: -4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box $justify="center" $overflow="auto">
|
||||
<Text
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './DocIcon';
|
||||
export * from './DocPage403';
|
||||
export * from './ModalEncryptDoc';
|
||||
export * from './ModalRemoveDoc';
|
||||
export * from './ModalRemoveDocEncryption';
|
||||
export * from './SimpleDocItem';
|
||||
|
||||
+71
-11
@@ -1,23 +1,81 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCollaborationUrl } from '@/core/config';
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { Base64, useProviderStore } from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
|
||||
import { useProviderStore } from '../stores/useProviderStore';
|
||||
import { Base64 } from '../types';
|
||||
|
||||
export const useCollaboration = (room?: string, initialContent?: Base64) => {
|
||||
export const useCollaboration = (
|
||||
room: string | undefined,
|
||||
initialContent: Base64 | undefined,
|
||||
isEncrypted: boolean | undefined,
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null,
|
||||
) => {
|
||||
const collaborationUrl = useCollaborationUrl(room);
|
||||
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
|
||||
const { provider, createProvider, destroyProvider } = useProviderStore();
|
||||
const { user } = useAuth();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const { provider, createProvider, destroyProvider, encryptionTransition } =
|
||||
useProviderStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!room || !collaborationUrl || provider) {
|
||||
if (
|
||||
!room ||
|
||||
!collaborationUrl ||
|
||||
!user ||
|
||||
isEncrypted === undefined ||
|
||||
(isEncrypted === true && !documentEncryptionSettings) ||
|
||||
(isEncrypted === true && !vaultClient) ||
|
||||
provider ||
|
||||
encryptionTransition
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newProvider = createProvider(collaborationUrl, room, initialContent);
|
||||
setBroadcastProvider(newProvider);
|
||||
const initialDocState = initialContent
|
||||
? Buffer.from(initialContent, 'base64')
|
||||
: undefined;
|
||||
|
||||
if (isEncrypted && documentEncryptionSettings && vaultClient) {
|
||||
(async () => {
|
||||
let decryptedState = initialDocState;
|
||||
|
||||
if (initialDocState) {
|
||||
// Decrypt initial document content via vault — pure ArrayBuffer
|
||||
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
|
||||
initialDocState.buffer as ArrayBuffer,
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
decryptedState = Buffer.from(decryptedBuffer);
|
||||
}
|
||||
|
||||
const newProvider = createProvider(
|
||||
collaborationUrl,
|
||||
room,
|
||||
decryptedState,
|
||||
{
|
||||
vaultClient,
|
||||
encryptedSymmetricKey:
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
},
|
||||
);
|
||||
|
||||
setBroadcastProvider(newProvider);
|
||||
})().catch((err) => {
|
||||
console.error('Failed to decrypt document content:', err);
|
||||
});
|
||||
} else {
|
||||
const newProvider = createProvider(
|
||||
collaborationUrl,
|
||||
room,
|
||||
initialDocState,
|
||||
);
|
||||
|
||||
setBroadcastProvider(newProvider);
|
||||
}
|
||||
}, [
|
||||
provider,
|
||||
collaborationUrl,
|
||||
@@ -25,11 +83,13 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
|
||||
initialContent,
|
||||
createProvider,
|
||||
setBroadcastProvider,
|
||||
user,
|
||||
isEncrypted,
|
||||
documentEncryptionSettings,
|
||||
vaultClient,
|
||||
encryptionTransition,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Destroy the provider when the component is unmounted
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (room) {
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ export const useCreateChildDocTree = (parentId?: string) => {
|
||||
|
||||
createChildDoc({
|
||||
parentId,
|
||||
isEncrypted: false,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
+215
-44
@@ -3,20 +3,45 @@ import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
|
||||
import * as Y from 'yjs';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Base64 } from '@/docs/doc-management';
|
||||
import {
|
||||
EncryptedWebSocket,
|
||||
createAdaptedEncryptedWebsocketClass,
|
||||
} from '@/docs/doc-collaboration/encryptedWebsocket';
|
||||
import { RelayProvider } from '@/docs/doc-collaboration/relayProvider';
|
||||
|
||||
export enum EncryptionTransitionEvent {
|
||||
ENCRYPTION_STARTED = 'system:encryption-started',
|
||||
ENCRYPTION_SUCCEEDED = 'system:encryption-succeeded',
|
||||
ENCRYPTION_CANCELED = 'system:encryption-canceled',
|
||||
REMOVE_ENCRYPTION_STARTED = 'system:remove-encryption-started',
|
||||
REMOVE_ENCRYPTION_SUCCEEDED = 'system:remove-encryption-succeeded',
|
||||
REMOVE_ENCRYPTION_CANCELED = 'system:remove-encryption-canceled',
|
||||
}
|
||||
|
||||
export type SwitchableProvider = RelayProvider | HocuspocusProvider;
|
||||
|
||||
export type EncryptionTransitionType = 'encrypting' | 'removing-encryption';
|
||||
|
||||
export interface UseCollaborationStore {
|
||||
createProvider: (
|
||||
providerUrl: string,
|
||||
storeId: string,
|
||||
initialDoc?: Base64,
|
||||
) => HocuspocusProvider;
|
||||
initialDocState?: Buffer<ArrayBuffer>,
|
||||
encryptionOptions?: {
|
||||
vaultClient: VaultClient;
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
},
|
||||
) => SwitchableProvider;
|
||||
destroyProvider: () => void;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
notifyOthers: (event: EncryptionTransitionEvent) => void;
|
||||
startEncryptionTransition: (type: EncryptionTransitionType) => void;
|
||||
clearEncryptionTransition: () => void;
|
||||
provider: SwitchableProvider | undefined;
|
||||
isConnected: boolean;
|
||||
isReady: boolean;
|
||||
isSynced: boolean;
|
||||
hasLostConnection: boolean;
|
||||
encryptionTransition: EncryptionTransitionType | null;
|
||||
resetLostConnection: () => void;
|
||||
}
|
||||
|
||||
@@ -26,48 +51,103 @@ const defaultValues = {
|
||||
isReady: false,
|
||||
isSynced: false,
|
||||
hasLostConnection: false,
|
||||
encryptionTransition: null,
|
||||
};
|
||||
|
||||
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
|
||||
function handleEncryptionSystemMessage(
|
||||
message: string,
|
||||
set: (partial: Partial<UseCollaborationStore>) => void,
|
||||
get: () => UseCollaborationStore,
|
||||
) {
|
||||
switch (message) {
|
||||
case EncryptionTransitionEvent.ENCRYPTION_STARTED:
|
||||
set({ encryptionTransition: 'encrypting' });
|
||||
break;
|
||||
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED:
|
||||
set({ encryptionTransition: 'removing-encryption' });
|
||||
break;
|
||||
case EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED:
|
||||
get().startEncryptionTransition('encrypting');
|
||||
break;
|
||||
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED:
|
||||
get().startEncryptionTransition('removing-encryption');
|
||||
break;
|
||||
case EncryptionTransitionEvent.ENCRYPTION_CANCELED:
|
||||
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED:
|
||||
set({ encryptionTransition: null });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
...defaultValues,
|
||||
createProvider: (wsUrl, storeId, initialDoc) => {
|
||||
createProvider: (wsUrl, storeId, initialDocState, encryptionOptions) => {
|
||||
const isEncrypted = !!encryptionOptions;
|
||||
|
||||
const doc = new Y.Doc({
|
||||
guid: storeId,
|
||||
});
|
||||
|
||||
if (initialDoc) {
|
||||
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
|
||||
if (initialDocState) {
|
||||
Y.applyUpdate(doc, initialDocState);
|
||||
}
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
url: wsUrl,
|
||||
name: storeId,
|
||||
document: doc,
|
||||
onDisconnect(data) {
|
||||
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
|
||||
if ((data.event as ExtendedCloseEvent).wasClean) {
|
||||
void provider.connect();
|
||||
let provider: SwitchableProvider;
|
||||
|
||||
if (isEncrypted) {
|
||||
//
|
||||
// TODO: should implement features for authentication (listening on message with custom payload?)
|
||||
// same for previous "onSynced"
|
||||
//
|
||||
|
||||
const AdaptedEncryptedWebSocket = createAdaptedEncryptedWebsocketClass({
|
||||
vaultClient: encryptionOptions!.vaultClient,
|
||||
encryptedSymmetricKey: encryptionOptions!.encryptedSymmetricKey,
|
||||
onSystemMessage: (message) => {
|
||||
if (message === 'system:authenticated') {
|
||||
set({ isReady: true, isConnected: true });
|
||||
} else {
|
||||
handleEncryptionSystemMessage(message, set, get);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
provider = new RelayProvider(wsUrl, storeId, doc, {
|
||||
WebSocketPolyfill: AdaptedEncryptedWebSocket,
|
||||
// For simplicity we always use websocket server even if there is local tabs,
|
||||
// otherwise the question would be do we need to encrypt also for local tabs through BroadcastChannel or not
|
||||
disableBc: true,
|
||||
});
|
||||
|
||||
provider.on('connection-close', (event) => {
|
||||
if (event) {
|
||||
if (event.wasClean) {
|
||||
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
|
||||
void provider.connect();
|
||||
} else if (event.code === 1000) {
|
||||
/**
|
||||
* Handle the "Reset Connection" event from the server
|
||||
* This is triggered when the server wants to reset the connection
|
||||
* for clients in the room.
|
||||
* A disconnect is made automatically but it takes time to be triggered,
|
||||
* so we force the disconnection here.
|
||||
*/
|
||||
provider.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
onAuthenticationFailed() {
|
||||
set({ isReady: true, isConnected: false });
|
||||
},
|
||||
onAuthenticated() {
|
||||
set({ isReady: true, isConnected: true });
|
||||
},
|
||||
onStatus: ({ status }) => {
|
||||
});
|
||||
|
||||
provider.on('status', (event) => {
|
||||
set((state) => {
|
||||
const nextConnected = status === WebSocketStatus.Connected;
|
||||
const nextConnected = event.status === 'connected';
|
||||
|
||||
/**
|
||||
* status === WebSocketStatus.Connected does not mean we are totally connected
|
||||
* status === 'connected' does not mean we are totally connected
|
||||
* because authentication can still be in progress and failed
|
||||
* So we only update isConnected when we loose the connection
|
||||
*/
|
||||
const connected =
|
||||
status !== WebSocketStatus.Connected
|
||||
event.status !== 'connected'
|
||||
? {
|
||||
isConnected: false,
|
||||
}
|
||||
@@ -75,30 +155,83 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
|
||||
return {
|
||||
...connected,
|
||||
isReady: state.isReady || status === WebSocketStatus.Disconnected,
|
||||
isReady: state.isReady || event.status === 'disconnected',
|
||||
hasLostConnection:
|
||||
state.isConnected && !nextConnected
|
||||
? true
|
||||
: state.hasLostConnection,
|
||||
};
|
||||
});
|
||||
},
|
||||
onSynced: ({ state }) => {
|
||||
});
|
||||
|
||||
provider.on('sync', (state) => {
|
||||
set({ isSynced: state, isReady: true });
|
||||
},
|
||||
onClose(data) {
|
||||
/**
|
||||
* Handle the "Reset Connection" event from the server
|
||||
* This is triggered when the server wants to reset the connection
|
||||
* for clients in the room.
|
||||
* A disconnect is made automatically but it takes time to be triggered,
|
||||
* so we force the disconnection here.
|
||||
*/
|
||||
if (data.event.code === 1000) {
|
||||
provider.disconnect();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
provider = new HocuspocusProvider({
|
||||
url: wsUrl,
|
||||
name: storeId,
|
||||
document: doc,
|
||||
onDisconnect(data) {
|
||||
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
|
||||
|
||||
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
|
||||
if ((data.event as ExtendedCloseEvent).wasClean) {
|
||||
void provider.connect();
|
||||
}
|
||||
},
|
||||
onAuthenticationFailed() {
|
||||
set({ isReady: true, isConnected: false });
|
||||
},
|
||||
onAuthenticated() {
|
||||
set({ isReady: true, isConnected: true });
|
||||
},
|
||||
onStatus: ({ status }) => {
|
||||
set((state) => {
|
||||
const nextConnected = status === WebSocketStatus.Connected;
|
||||
|
||||
/**
|
||||
* status === WebSocketStatus.Connected does not mean we are totally connected
|
||||
* because authentication can still be in progress and failed
|
||||
* So we only update isConnected when we loose the connection
|
||||
*/
|
||||
const connected =
|
||||
status !== WebSocketStatus.Connected
|
||||
? {
|
||||
isConnected: false,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...connected,
|
||||
isReady: state.isReady || status === WebSocketStatus.Disconnected,
|
||||
hasLostConnection:
|
||||
state.isConnected && !nextConnected
|
||||
? true
|
||||
: state.hasLostConnection,
|
||||
};
|
||||
});
|
||||
},
|
||||
onStateless: ({ payload }) => {
|
||||
handleEncryptionSystemMessage(payload, set, get);
|
||||
},
|
||||
onSynced: ({ state }) => {
|
||||
set({ isSynced: state, isReady: true });
|
||||
},
|
||||
onClose(data) {
|
||||
/**
|
||||
* Handle the "Reset Connection" event from the server
|
||||
* This is triggered when the server wants to reset the connection
|
||||
* for clients in the room.
|
||||
* A disconnect is made automatically but it takes time to be triggered,
|
||||
* so we force the disconnection here.
|
||||
*/
|
||||
if (data.event.code === 1000) {
|
||||
provider.disconnect();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
set({
|
||||
provider,
|
||||
@@ -106,6 +239,44 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
|
||||
return provider;
|
||||
},
|
||||
startEncryptionTransition: (type: EncryptionTransitionType) => {
|
||||
const provider = get().provider;
|
||||
|
||||
// switching between hocuspocus and relay servers, we have to properly close the current one
|
||||
if (provider) {
|
||||
provider.destroy();
|
||||
}
|
||||
|
||||
// set the right data so the page component has the indication it needs to fetch again document data
|
||||
set({
|
||||
encryptionTransition: type,
|
||||
provider: undefined,
|
||||
isConnected: false,
|
||||
isReady: false,
|
||||
isSynced: false,
|
||||
hasLostConnection: false,
|
||||
});
|
||||
},
|
||||
clearEncryptionTransition: () => {
|
||||
set({ encryptionTransition: null });
|
||||
},
|
||||
notifyOthers: (event: EncryptionTransitionEvent) => {
|
||||
const provider = get().provider;
|
||||
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider instanceof HocuspocusProvider) {
|
||||
provider.sendStateless(event);
|
||||
} else if (provider instanceof RelayProvider) {
|
||||
const ws = provider.ws as EncryptedWebSocket | null;
|
||||
|
||||
if (ws) {
|
||||
ws.sendSystemMessage(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
destroyProvider: () => {
|
||||
const provider = get().provider;
|
||||
if (provider) {
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface Doc {
|
||||
depth: number;
|
||||
path: string;
|
||||
is_favorite: boolean;
|
||||
is_encrypted: boolean;
|
||||
link_reach: LinkReach;
|
||||
link_role?: LinkRole;
|
||||
nb_accesses_direct: number;
|
||||
@@ -71,6 +72,9 @@ export interface Doc {
|
||||
numchild: number;
|
||||
updated_at: string;
|
||||
user_role: Role;
|
||||
encrypted_document_symmetric_key_for_user?: string;
|
||||
accesses_user_ids?: string[];
|
||||
accesses_fingerprints_per_user?: Record<string, string>;
|
||||
abilities: {
|
||||
accesses_manage: boolean;
|
||||
accesses_view: boolean;
|
||||
|
||||
@@ -3,6 +3,75 @@ import * as Y from 'yjs';
|
||||
|
||||
import { Doc, LinkReach } from './types';
|
||||
|
||||
const UUID =
|
||||
'[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}';
|
||||
const ATTACHMENT_KEY_REGEX = new RegExp(
|
||||
`^/media/(${UUID}/attachments/${UUID}(?:-unsafe)?\\.[a-zA-Z0-9]{1,10})$`,
|
||||
);
|
||||
|
||||
export type AttachmentKeyMetadata = {
|
||||
mediaUrl: string;
|
||||
name?: string;
|
||||
nodes: Y.XmlElement[];
|
||||
};
|
||||
|
||||
function traverseYDoc(
|
||||
node: Y.XmlElement | Y.XmlFragment,
|
||||
callback: (el: Y.XmlElement) => void,
|
||||
) {
|
||||
if (node instanceof Y.XmlElement) {
|
||||
callback(node);
|
||||
}
|
||||
|
||||
node.toArray().forEach((child) => {
|
||||
if (child instanceof Y.XmlElement || child instanceof Y.XmlFragment) {
|
||||
traverseYDoc(child, callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique attachment S3 keys and their Yjs node references
|
||||
* from the 'document-store' XmlFragment of a Y.Doc.
|
||||
*/
|
||||
export const extractAttachmentKeysAndMetadata = (
|
||||
yDoc: Y.Doc,
|
||||
): Map<string, AttachmentKeyMetadata> => {
|
||||
const fragment = yDoc.getXmlFragment('document-store');
|
||||
const keysAndMetadata = new Map<string, AttachmentKeyMetadata>();
|
||||
|
||||
yDoc.transact(() => {
|
||||
traverseYDoc(fragment, (node) => {
|
||||
const urlAttributeValue = node.getAttribute('url');
|
||||
|
||||
if (urlAttributeValue) {
|
||||
const url = new URL(urlAttributeValue);
|
||||
const match = ATTACHMENT_KEY_REGEX.exec(url.pathname);
|
||||
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
const keyMetadata = keysAndMetadata.get(key);
|
||||
|
||||
if (keyMetadata) {
|
||||
keyMetadata.nodes.push(node);
|
||||
} else {
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
|
||||
keysAndMetadata.set(key, {
|
||||
mediaUrl: url.toString(),
|
||||
name: node.getAttribute('name'),
|
||||
nodes: [node],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return keysAndMetadata;
|
||||
};
|
||||
|
||||
export const base64ToYDoc = (base64: string) => {
|
||||
const uint8Array = Buffer.from(base64, 'base64');
|
||||
const ydoc = new Y.Doc();
|
||||
|
||||
@@ -20,18 +20,21 @@ interface CreateDocAccessParams {
|
||||
role: Role;
|
||||
docId: Doc['id'];
|
||||
memberId: User['id'];
|
||||
memberEncryptedSymmetricKey: string | null;
|
||||
}
|
||||
|
||||
export const createDocAccess = async ({
|
||||
memberId,
|
||||
role,
|
||||
docId,
|
||||
memberEncryptedSymmetricKey,
|
||||
}: CreateDocAccessParams): Promise<Access> => {
|
||||
const response = await fetchAPI(`documents/${docId}/accesses/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user_id: memberId,
|
||||
role,
|
||||
encrypted_document_symmetric_key_for_user: memberEncryptedSymmetricKey,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+72
-10
@@ -9,8 +9,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { APIError } from '@/api';
|
||||
import { Box, Card } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { Doc, Role } from '@/docs/doc-management';
|
||||
import { User } from '@/features/auth';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
|
||||
import { OptionType } from '../types';
|
||||
@@ -25,6 +28,7 @@ type APIErrorUser = APIError<{
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
selectedUsers: User[];
|
||||
onRemoveUser?: (user: User) => void;
|
||||
onSubmit?: (selectedUsers: User[], role: Role) => void;
|
||||
@@ -32,12 +36,14 @@ type Props = {
|
||||
};
|
||||
export const DocShareAddMemberList = ({
|
||||
doc,
|
||||
documentEncryptionSettings,
|
||||
selectedUsers,
|
||||
onRemoveUser,
|
||||
afterInvite,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
@@ -72,6 +78,10 @@ export const DocShareAddMemberList = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (dataError.cause?.[0] && dataError.message === dataError.cause[0]) {
|
||||
messageError = dataError.cause[0];
|
||||
}
|
||||
|
||||
toast(messageError, VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
@@ -79,7 +89,22 @@ export const DocShareAddMemberList = ({
|
||||
|
||||
const onInvite = async () => {
|
||||
setIsLoading(true);
|
||||
const promises = selectedUsers.map((user) => {
|
||||
|
||||
// Fetch all public keys in a single request before processing users
|
||||
let publicKeysMap: Record<string, ArrayBuffer> = {};
|
||||
|
||||
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
|
||||
const memberUserIds = selectedUsers
|
||||
.filter((user) => user.id !== user.email && user.suite_user_id)
|
||||
.map((user) => user.suite_user_id!);
|
||||
|
||||
if (memberUserIds.length > 0) {
|
||||
const { publicKeys } = await vaultClient.fetchPublicKeys(memberUserIds);
|
||||
publicKeysMap = publicKeys;
|
||||
}
|
||||
}
|
||||
|
||||
const promises = selectedUsers.map(async (user) => {
|
||||
const isInvitationMode = user.id === user.email;
|
||||
|
||||
const payload = {
|
||||
@@ -87,15 +112,52 @@ export const DocShareAddMemberList = ({
|
||||
docId: doc.id,
|
||||
};
|
||||
|
||||
return isInvitationMode
|
||||
? createInvitation({
|
||||
...payload,
|
||||
email: user.email.toLowerCase(),
|
||||
})
|
||||
: createDocAccess({
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
});
|
||||
if (isInvitationMode) {
|
||||
if (doc.is_encrypted) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
t(
|
||||
'Only registered users with encryption enabled can be added to encrypted documents.',
|
||||
),
|
||||
),
|
||||
{
|
||||
cause: [
|
||||
t(
|
||||
'Only registered users with encryption enabled can be added to encrypted documents.',
|
||||
),
|
||||
],
|
||||
data: { value: user.email, type: OptionType.INVITATION },
|
||||
},
|
||||
) as APIErrorUser;
|
||||
}
|
||||
|
||||
return createInvitation({
|
||||
...payload,
|
||||
email: user.email.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
// For encrypted docs, re-wrap the symmetric key for the new member via vault
|
||||
let memberEncryptedSymmetricKey: string | null = null;
|
||||
|
||||
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
|
||||
const userPublicKey = user.suite_user_id ? publicKeysMap[user.suite_user_id] : undefined;
|
||||
|
||||
if (userPublicKey) {
|
||||
const { rewrappedKey } = await vaultClient.rewrapKey(
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
userPublicKey,
|
||||
);
|
||||
|
||||
memberEncryptedSymmetricKey = toBase64(new Uint8Array(rewrappedKey));
|
||||
}
|
||||
}
|
||||
|
||||
return createDocAccess({
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
memberEncryptedSymmetricKey,
|
||||
});
|
||||
});
|
||||
|
||||
const settledPromises = await Promise.allSettled(promises);
|
||||
|
||||
+7
@@ -31,6 +31,7 @@ export const DocShareInvitationItem = ({
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const invitedUser: User = {
|
||||
id: invitation.email,
|
||||
suite_user_id: null,
|
||||
full_name: invitation.email,
|
||||
email: invitation.email,
|
||||
short_name: invitation.email,
|
||||
@@ -91,9 +92,13 @@ export const DocShareInvitationItem = ({
|
||||
|
||||
type DocShareModalInviteUserRowProps = {
|
||||
user: User;
|
||||
suffix?: string;
|
||||
fingerprintKey?: string | null;
|
||||
};
|
||||
export const DocShareModalInviteUserRow = ({
|
||||
user,
|
||||
suffix,
|
||||
fingerprintKey,
|
||||
}: DocShareModalInviteUserRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -104,6 +109,8 @@ export const DocShareModalInviteUserRow = ({
|
||||
>
|
||||
<SearchUserRow
|
||||
user={user}
|
||||
suffix={suffix}
|
||||
fingerprintKey={fingerprintKey}
|
||||
right={
|
||||
<BoxButton
|
||||
className="right-hover"
|
||||
|
||||
+69
-4
@@ -2,30 +2,38 @@ import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { QuickSearchData } from '@/components/quick-search';
|
||||
import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { PublicKeyMismatch } from '@/docs/doc-collaboration/hook/usePublicKeyRegistry';
|
||||
import { Access, Doc, Role } from '@/docs/doc-management/';
|
||||
|
||||
import { useDocAccesses, useUpdateDocAccess } from '../api';
|
||||
import { useWhoAmI } from '../hooks/';
|
||||
|
||||
import { DocRoleDropdown } from './DocRoleDropdown';
|
||||
import { ModalKeyMismatch } from './ModalKeyMismatch';
|
||||
import { SearchUserRow } from './SearchUserRow';
|
||||
|
||||
type Props = {
|
||||
doc?: Doc;
|
||||
access: Access;
|
||||
isInherited?: boolean;
|
||||
suffix?: string;
|
||||
onSuffixClick?: () => void;
|
||||
fingerprintKey?: string | null;
|
||||
};
|
||||
export const DocShareMemberItem = ({
|
||||
doc,
|
||||
access,
|
||||
isInherited = false,
|
||||
suffix,
|
||||
onSuffixClick,
|
||||
fingerprintKey,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isLastOwner } = useWhoAmI(access);
|
||||
@@ -69,6 +77,9 @@ export const DocShareMemberItem = ({
|
||||
<SearchUserRow
|
||||
alwaysShowRight={true}
|
||||
user={access.user}
|
||||
suffix={suffix}
|
||||
onSuffixClick={onSuffixClick}
|
||||
fingerprintKey={fingerprintKey}
|
||||
right={
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
|
||||
<DocRoleDropdown
|
||||
@@ -93,12 +104,19 @@ export const DocShareMemberItem = ({
|
||||
|
||||
interface QuickSearchGroupMemberProps {
|
||||
doc: Doc;
|
||||
keyMismatchUserIds?: Set<string>;
|
||||
keyMismatches?: PublicKeyMismatch[];
|
||||
acceptNewKey?: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const QuickSearchGroupMember = ({
|
||||
doc,
|
||||
keyMismatchUserIds,
|
||||
keyMismatches,
|
||||
acceptNewKey,
|
||||
}: QuickSearchGroupMemberProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [mismatchUserId, setMismatchUserId] = useState<string | null>(null);
|
||||
const membersQuery = useDocAccesses({
|
||||
docId: doc.id,
|
||||
});
|
||||
@@ -124,10 +142,57 @@ export const QuickSearchGroupMember = ({
|
||||
<Box aria-label={t('List members card')} $padding={{ bottom: '3xs' }}>
|
||||
<QuickSearchGroup
|
||||
group={membersData}
|
||||
renderElement={(access) => (
|
||||
<DocShareMemberItem doc={doc} access={access} />
|
||||
)}
|
||||
renderElement={(access) => {
|
||||
const uid = access.user.suite_user_id;
|
||||
const hasMismatch = uid ? keyMismatchUserIds?.has(uid) : false;
|
||||
const hasNoEncryptionKey =
|
||||
doc.is_encrypted &&
|
||||
(!uid || !doc.accesses_fingerprints_per_user?.[uid]);
|
||||
|
||||
let suffix: string | undefined;
|
||||
if (hasMismatch) {
|
||||
suffix = t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
|
||||
} else if (hasNoEncryptionKey) {
|
||||
suffix = t(
|
||||
'ENCRYPTION DISABLED - consider removing this member since unable to read the document',
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DocShareMemberItem
|
||||
doc={doc}
|
||||
access={access}
|
||||
suffix={suffix}
|
||||
onSuffixClick={
|
||||
hasMismatch && uid
|
||||
? () => setMismatchUserId(uid)
|
||||
: undefined
|
||||
}
|
||||
fingerprintKey={
|
||||
uid ? doc.accesses_fingerprints_per_user?.[uid] : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{mismatchUserId &&
|
||||
(() => {
|
||||
const mismatch = keyMismatches?.find(
|
||||
(m) => m.userId === mismatchUserId,
|
||||
);
|
||||
return (
|
||||
<ModalKeyMismatch
|
||||
onClose={() => setMismatchUserId(null)}
|
||||
onAcceptKey={
|
||||
acceptNewKey
|
||||
? () => void acceptNewKey(mismatchUserId)
|
||||
: undefined
|
||||
}
|
||||
knownKey={mismatch?.knownKey}
|
||||
currentKey={mismatch?.currentKey}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
+352
-115
@@ -1,18 +1,32 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { Box, ButtonCloseModal, HorizontalSeparator, Text } from '@/components';
|
||||
import {
|
||||
Box,
|
||||
ButtonCloseModal,
|
||||
HorizontalSeparator,
|
||||
Icon,
|
||||
Loading,
|
||||
Text,
|
||||
} from '@/components';
|
||||
import {
|
||||
QuickSearch,
|
||||
QuickSearchData,
|
||||
QuickSearchGroup,
|
||||
} from '@/components/quick-search/';
|
||||
import {
|
||||
useDocumentEncryption,
|
||||
usePublicKeyRegistry,
|
||||
useUserEncryption,
|
||||
} from '@/docs/doc-collaboration';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import type { PublicKeyMismatch } from '@/docs/doc-collaboration/hook/usePublicKeyRegistry';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { User } from '@/features/auth';
|
||||
import { User, useAuth } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
import { isValidEmail } from '@/utils';
|
||||
|
||||
@@ -37,6 +51,7 @@ import {
|
||||
} from './DocShareInvitation';
|
||||
import { QuickSearchGroupMember } from './DocShareMember';
|
||||
import { DocShareModalFooter } from './DocShareModalFooter';
|
||||
import { ModalKeyMismatch } from './ModalKeyMismatch';
|
||||
|
||||
const ShareModalStyle = createGlobalStyle`
|
||||
.--docs--doc-share-modal [cmdk-item] {
|
||||
@@ -49,16 +64,53 @@ const ShareModalStyle = createGlobalStyle`
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
documentEncryptionSettings?: DocumentEncryptionSettings | null;
|
||||
isRootDoc?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
export const DocShareModal = ({
|
||||
doc,
|
||||
documentEncryptionSettings,
|
||||
onClose,
|
||||
isRootDoc = true,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedUsersRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { user } = useAuth();
|
||||
|
||||
// When document encryption settings exist they should be passed as prop, on it will use this fallback
|
||||
// that's because in some cases we want them to only be computed at this step (to avoid computing just when listed in a list)
|
||||
const needsDerivation = !documentEncryptionSettings;
|
||||
const { encryptionLoading, encryptionError } = useUserEncryption();
|
||||
const {
|
||||
documentEncryptionLoading,
|
||||
documentEncryptionSettings: derivedEncryptionSettings,
|
||||
documentEncryptionError,
|
||||
} = useDocumentEncryption(
|
||||
needsDerivation ? doc.is_encrypted : undefined,
|
||||
needsDerivation ? doc.encrypted_document_symmetric_key_for_user : undefined,
|
||||
);
|
||||
const effectiveEncryptionSettings =
|
||||
documentEncryptionSettings ?? derivedEncryptionSettings ?? null;
|
||||
const isEncryptionDeriving =
|
||||
needsDerivation && (encryptionLoading || documentEncryptionLoading);
|
||||
const derivedEncryptionError =
|
||||
needsDerivation && doc.is_encrypted
|
||||
? encryptionError || documentEncryptionError
|
||||
: null;
|
||||
|
||||
const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry(
|
||||
undefined,
|
||||
user?.suite_user_id ?? undefined,
|
||||
);
|
||||
const keyMismatchUserIds = useMemo(
|
||||
() => new Set(keyMismatches.map((m) => m.userId)),
|
||||
[keyMismatches],
|
||||
);
|
||||
|
||||
/**
|
||||
* The modal content height is calculated based on the viewport height.
|
||||
@@ -214,136 +266,229 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
>
|
||||
{liveAnnouncement}
|
||||
</div>
|
||||
<Box
|
||||
$height="auto"
|
||||
$maxHeight={canViewAccesses ? modalContentHeight : 'none'}
|
||||
$overflow="hidden"
|
||||
className="--docs--doc-share-modal noPadding "
|
||||
$justify="space-between"
|
||||
role="dialog"
|
||||
aria-label={t('Share modal content')}
|
||||
>
|
||||
{isEncryptionDeriving && <Loading />}
|
||||
{!isEncryptionDeriving && derivedEncryptionError && (
|
||||
<Box $align="center" $gap="sm" $padding="lg">
|
||||
<Icon iconName="lock" $size="2rem" $theme="warning" />
|
||||
<Text as="h3" $textAlign="center" $margin="0">
|
||||
{t('Encryption keys unavailable')}
|
||||
</Text>
|
||||
<Text $variation="secondary" $textAlign="center" $size="sm">
|
||||
{t(
|
||||
'This is an encrypted document, but your current device does not have the required encryption keys to decrypt it.',
|
||||
)}
|
||||
</Text>
|
||||
{(encryptionError === 'missing_private_key' ||
|
||||
encryptionError === 'missing_public_key') && (
|
||||
<Text $variation="secondary" $textAlign="center" $size="sm">
|
||||
{t(
|
||||
'This usually happens when you switch to a new device or browser without restoring your encryption backup, please go to your "Encryption Settings" to fix it.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{documentEncryptionError === 'missing_symmetric_key' && (
|
||||
<Text $variation="secondary" $textAlign="center" $size="sm">
|
||||
{t(
|
||||
'You do not have access to this encrypted document. Ask the document owner to share it with you again.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{documentEncryptionError === 'decryption_failed' && (
|
||||
<Text $variation="secondary" $textAlign="center" $size="sm">
|
||||
{t(
|
||||
'Your encryption keys could not decrypt this document. This may happen if your keys were recreated. Ask the document owner to share it with you again.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!isEncryptionDeriving && !derivedEncryptionError && (
|
||||
<Box
|
||||
$flex={1}
|
||||
$css={css`
|
||||
[cmdk-list] {
|
||||
overflow-y: auto;
|
||||
height: ${listHeight};
|
||||
}
|
||||
`}
|
||||
$height="auto"
|
||||
$maxHeight={canViewAccesses ? modalContentHeight : 'none'}
|
||||
$overflow="hidden"
|
||||
className="--docs--doc-share-modal noPadding "
|
||||
$justify="space-between"
|
||||
role="dialog"
|
||||
aria-label={t('Share modal content')}
|
||||
>
|
||||
<Box ref={selectedUsersRef}>
|
||||
{canShare && selectedUsers.length > 0 && (
|
||||
<Box $padding={{ horizontal: 'base' }} $margin={{ top: '12x' }}>
|
||||
<DocShareAddMemberList
|
||||
doc={doc}
|
||||
selectedUsers={selectedUsers}
|
||||
onRemoveUser={onRemoveUser}
|
||||
afterInvite={() => {
|
||||
setUserQuery('');
|
||||
setInputValue('');
|
||||
setSelectedUsers([]);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!canViewAccesses && <HorizontalSeparator customPadding="12px" />}
|
||||
</Box>
|
||||
|
||||
<Box data-testid="doc-share-quick-search">
|
||||
{!canViewAccesses && (
|
||||
<Box
|
||||
$height={listHeight}
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$gap="1rem"
|
||||
>
|
||||
<Text
|
||||
$maxWidth="320px"
|
||||
$textAlign="center"
|
||||
$variation="secondary"
|
||||
$size="sm"
|
||||
as="p"
|
||||
<Box
|
||||
$flex={1}
|
||||
$css={css`
|
||||
[cmdk-list] {
|
||||
overflow-y: auto;
|
||||
height: ${listHeight};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Box ref={selectedUsersRef}>
|
||||
{canShare && selectedUsers.length > 0 && (
|
||||
<Box
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$margin={{ top: '12x' }}
|
||||
>
|
||||
{t(
|
||||
'You can view this document but need additional access to see its members or modify settings.',
|
||||
)}
|
||||
</Text>
|
||||
<ButtonAccessRequest
|
||||
docId={doc.id}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{canViewAccesses && (
|
||||
<QuickSearch
|
||||
label={t('Search results')}
|
||||
onFilter={(str) => {
|
||||
setInputValue(str);
|
||||
onFilter(str);
|
||||
}}
|
||||
inputValue={inputValue}
|
||||
showInput={canShare}
|
||||
loading={searchUsersQuery.isLoading}
|
||||
placeholder={t('Type a name or email')}
|
||||
>
|
||||
{showInheritedShareContent && (
|
||||
<DocInheritedShareContent
|
||||
rawAccesses={
|
||||
membersQuery?.filter(
|
||||
(access) => access.document.id !== doc.id,
|
||||
) ?? []
|
||||
}
|
||||
<DocShareAddMemberList
|
||||
doc={doc}
|
||||
documentEncryptionSettings={effectiveEncryptionSettings}
|
||||
selectedUsers={selectedUsers}
|
||||
onRemoveUser={onRemoveUser}
|
||||
afterInvite={() => {
|
||||
setUserQuery('');
|
||||
setInputValue('');
|
||||
setSelectedUsers([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showMemberSection && isRootDoc && (
|
||||
<Box $padding={{ horizontal: 'base' }}>
|
||||
<QuickSearchGroupAccessRequest doc={doc} />
|
||||
<QuickSearchGroupInvitation doc={doc} />
|
||||
<QuickSearchGroupMember doc={doc} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!canViewAccesses && (
|
||||
<HorizontalSeparator customPadding="12px" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!showMemberSection && canShare && (
|
||||
<QuickSearchInviteInputSection
|
||||
searchUsersRawData={searchUsersQuery.data}
|
||||
onSelect={onSelect}
|
||||
userQuery={userQuery}
|
||||
<Box data-testid="doc-share-quick-search">
|
||||
{!canViewAccesses && (
|
||||
<Box
|
||||
$height={listHeight}
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$gap="1rem"
|
||||
>
|
||||
<Text
|
||||
$maxWidth="320px"
|
||||
$textAlign="center"
|
||||
$variation="secondary"
|
||||
$size="sm"
|
||||
as="p"
|
||||
>
|
||||
{t(
|
||||
'You can view this document but need additional access to see its members or modify settings.',
|
||||
)}
|
||||
</Text>
|
||||
<ButtonAccessRequest
|
||||
docId={doc.id}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</QuickSearch>
|
||||
</Box>
|
||||
)}
|
||||
{canViewAccesses && (
|
||||
<QuickSearch
|
||||
label={t('Search results')}
|
||||
onFilter={(str) => {
|
||||
setInputValue(str);
|
||||
onFilter(str);
|
||||
}}
|
||||
inputValue={inputValue}
|
||||
showInput={canShare}
|
||||
loading={searchUsersQuery.isLoading}
|
||||
placeholder={t('Type a name or email')}
|
||||
>
|
||||
{showInheritedShareContent && (
|
||||
<DocInheritedShareContent
|
||||
rawAccesses={
|
||||
membersQuery?.filter(
|
||||
(access) => access.document.id !== doc.id,
|
||||
) ?? []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showMemberSection && isRootDoc && (
|
||||
<Box $padding={{ horizontal: 'base' }}>
|
||||
<QuickSearchGroupAccessRequest doc={doc} />
|
||||
<QuickSearchGroupInvitation doc={doc} />
|
||||
<QuickSearchGroupMember
|
||||
doc={doc}
|
||||
keyMismatchUserIds={keyMismatchUserIds}
|
||||
keyMismatches={keyMismatches}
|
||||
acceptNewKey={acceptNewKey}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!showMemberSection && canShare && (
|
||||
<QuickSearchInviteInputSection
|
||||
doc={doc}
|
||||
searchUsersRawData={searchUsersQuery.data}
|
||||
onSelect={onSelect}
|
||||
userQuery={userQuery}
|
||||
isEncrypted={doc.is_encrypted}
|
||||
keyMismatchUserIds={keyMismatchUserIds}
|
||||
keyMismatches={keyMismatches}
|
||||
acceptNewKey={acceptNewKey}
|
||||
/>
|
||||
)}
|
||||
</QuickSearch>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box ref={handleRef}>
|
||||
{showFooter && (
|
||||
<DocShareModalFooter doc={doc} onClose={onClose} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box ref={handleRef}>
|
||||
{showFooter && <DocShareModalFooter doc={doc} onClose={onClose} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface QuickSearchInviteInputSectionProps {
|
||||
doc: Doc;
|
||||
onSelect: (usr: User) => void;
|
||||
searchUsersRawData: User[] | undefined;
|
||||
userQuery: string;
|
||||
isEncrypted: boolean;
|
||||
keyMismatchUserIds?: Set<string>;
|
||||
keyMismatches?: PublicKeyMismatch[];
|
||||
acceptNewKey?: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const QuickSearchInviteInputSection = ({
|
||||
doc,
|
||||
onSelect,
|
||||
searchUsersRawData,
|
||||
userQuery,
|
||||
isEncrypted,
|
||||
keyMismatchUserIds,
|
||||
keyMismatches,
|
||||
acceptNewKey,
|
||||
}: QuickSearchInviteInputSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showNoKeyModal, setShowNoKeyModal] = useState(false);
|
||||
const [mismatchUser, setMismatchUser] = useState<User | null>(null);
|
||||
|
||||
const showEncryptedInviteWarning = useMemo(() => {
|
||||
const users = searchUsersRawData || [];
|
||||
const isEmail = isValidEmail(userQuery);
|
||||
const hasEmailInUsers = users.some(
|
||||
(user) => user.email.toLowerCase() === userQuery.toLowerCase(),
|
||||
);
|
||||
return isEncrypted && isEmail && !hasEmailInUsers;
|
||||
}, [searchUsersRawData, userQuery, isEncrypted]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(user: User) => {
|
||||
if (isEncrypted && (!user.suite_user_id || !doc.accesses_fingerprints_per_user?.[user.suite_user_id])) {
|
||||
setShowNoKeyModal(true);
|
||||
return;
|
||||
}
|
||||
if (user.suite_user_id && keyMismatchUserIds?.has(user.suite_user_id)) {
|
||||
setMismatchUser(user);
|
||||
return;
|
||||
}
|
||||
onSelect(user);
|
||||
},
|
||||
[isEncrypted, doc.accesses_fingerprints_per_user, keyMismatchUserIds, onSelect],
|
||||
);
|
||||
|
||||
const searchUserData: QuickSearchData<User> = useMemo(() => {
|
||||
const users = searchUsersRawData || [];
|
||||
const isEmail = isValidEmail(userQuery);
|
||||
const newUser: User = {
|
||||
id: userQuery,
|
||||
suite_user_id: null,
|
||||
full_name: '',
|
||||
email: userQuery,
|
||||
short_name: '',
|
||||
@@ -354,20 +499,34 @@ const QuickSearchInviteInputSection = ({
|
||||
(user) => user.email.toLowerCase() === userQuery.toLowerCase(),
|
||||
);
|
||||
|
||||
const showInviteByEmail = isEmail && !hasEmailInUsers && !isEncrypted;
|
||||
|
||||
return {
|
||||
groupName: t('Search user result'),
|
||||
elements: users,
|
||||
endActions:
|
||||
isEmail && !hasEmailInUsers
|
||||
? [
|
||||
{
|
||||
content: <DocShareModalInviteUserRow user={newUser} />,
|
||||
onSelect: () => void onSelect(newUser),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
endActions: showInviteByEmail
|
||||
? [
|
||||
{
|
||||
content: <DocShareModalInviteUserRow user={newUser} />,
|
||||
onSelect: () => void handleSelect(newUser),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}, [onSelect, searchUsersRawData, t, userQuery]);
|
||||
}, [handleSelect, searchUsersRawData, t, userQuery, isEncrypted]);
|
||||
|
||||
const getUserSuffix = useCallback(
|
||||
(user: User): string | undefined => {
|
||||
if (user.suite_user_id && keyMismatchUserIds?.has(user.suite_user_id)) {
|
||||
return t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
|
||||
}
|
||||
if (isEncrypted && (!user.suite_user_id || !doc.accesses_fingerprints_per_user?.[user.suite_user_id])) {
|
||||
return t(`(encryption not enabled)`);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[isEncrypted, doc.accesses_fingerprints_per_user, keyMismatchUserIds, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -376,9 +535,87 @@ const QuickSearchInviteInputSection = ({
|
||||
>
|
||||
<QuickSearchGroup
|
||||
group={searchUserData}
|
||||
onSelect={onSelect}
|
||||
renderElement={(user) => <DocShareModalInviteUserRow user={user} />}
|
||||
onSelect={handleSelect}
|
||||
renderElement={(user) => (
|
||||
<DocShareModalInviteUserRow
|
||||
user={user}
|
||||
suffix={getUserSuffix(user)}
|
||||
fingerprintKey={user.suite_user_id ? doc.accesses_fingerprints_per_user?.[user.suite_user_id] : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{showEncryptedInviteWarning && (
|
||||
<Text
|
||||
$variation="secondary"
|
||||
$size="sm"
|
||||
$padding={{ horizontal: 'xs', top: '3xs' }}
|
||||
>
|
||||
{t(
|
||||
'Only registered users with encryption enabled can be added to encrypted documents.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{showNoKeyModal && (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => setShowNoKeyModal(false)}
|
||||
size={ModalSize.SMALL}
|
||||
rightActions={
|
||||
<Button onClick={() => setShowNoKeyModal(false)}>
|
||||
{t('Understood')}
|
||||
</Button>
|
||||
}
|
||||
title={
|
||||
<Text
|
||||
as="h1"
|
||||
$gap="0.7rem"
|
||||
$size="h6"
|
||||
$align="flex-start"
|
||||
$direction="row"
|
||||
$margin="0"
|
||||
>
|
||||
<Icon iconName="lock" />
|
||||
{t('Encryption required')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
|
||||
<Text $variation="secondary">
|
||||
{t(
|
||||
'This user has not enabled encryption on their account yet. It is not possible to share encrypted content with them.',
|
||||
)}
|
||||
</Text>
|
||||
<Text $variation="secondary">
|
||||
{t(
|
||||
'Please ask them to enable encryption in their account settings first.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Modal>
|
||||
)}
|
||||
{mismatchUser &&
|
||||
(() => {
|
||||
const mismatch = keyMismatches?.find(
|
||||
(m) => m.userId === mismatchUser.suite_user_id,
|
||||
);
|
||||
return (
|
||||
<ModalKeyMismatch
|
||||
onClose={() => setMismatchUser(null)}
|
||||
onAcceptKey={
|
||||
acceptNewKey
|
||||
? () => {
|
||||
void acceptNewKey(mismatchUser.suite_user_id!).then(() => {
|
||||
onSelect(mismatchUser);
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
knownKey={mismatch?.knownKey}
|
||||
currentKey={mismatch?.currentKey}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-2
@@ -27,8 +27,12 @@ export const DocShareModalFooter = ({
|
||||
>
|
||||
<HorizontalSeparator $withPadding={true} customPadding="12px" />
|
||||
|
||||
<DocVisibility doc={doc} />
|
||||
<HorizontalSeparator customPadding="12px" />
|
||||
{!doc.is_encrypted && (
|
||||
<>
|
||||
<DocVisibility doc={doc} />
|
||||
<HorizontalSeparator customPadding="12px" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box
|
||||
$direction="row"
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { useKeyFingerprint } from '@/docs/doc-collaboration';
|
||||
|
||||
interface ModalKeyMismatchProps {
|
||||
onClose: () => void;
|
||||
onAcceptKey?: () => void;
|
||||
knownKey?: string;
|
||||
currentKey?: string;
|
||||
}
|
||||
|
||||
export const ModalKeyMismatch = ({
|
||||
onClose,
|
||||
onAcceptKey,
|
||||
knownKey,
|
||||
currentKey,
|
||||
}: ModalKeyMismatchProps) => {
|
||||
const { t } = useTranslation();
|
||||
const knownFingerprint = useKeyFingerprint(knownKey);
|
||||
const currentFingerprint = useKeyFingerprint(currentKey);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={onClose}
|
||||
size={ModalSize.MEDIUM}
|
||||
rightActions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
{onAcceptKey && (
|
||||
<Button
|
||||
color="warning"
|
||||
onClick={() => {
|
||||
onAcceptKey();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('I trust this key')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
title={
|
||||
<Text
|
||||
as="h1"
|
||||
$gap="0.7rem"
|
||||
$size="h6"
|
||||
$align="flex-start"
|
||||
$direction="row"
|
||||
$margin="0"
|
||||
>
|
||||
<Icon iconName="warning" $theme="warning" />
|
||||
{t('Public key change detected')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box $direction="column" $gap="0.5rem" $margin={{ top: 'sm' }}>
|
||||
<Text $variation="secondary">
|
||||
{t(
|
||||
"This user's encryption public key has changed since you last interacted with them.",
|
||||
)}
|
||||
</Text>
|
||||
<Text $variation="secondary">
|
||||
{t(
|
||||
'This could mean the user has regenerated their encryption keys, but it could also indicate that their account has been compromised.',
|
||||
)}
|
||||
</Text>
|
||||
<Text $variation="secondary" $weight="600">
|
||||
{t(
|
||||
'We recommend verifying with this person directly (e.g. via video call) that they have indeed changed their encryption key before proceeding.',
|
||||
)}
|
||||
</Text>
|
||||
{(knownFingerprint || currentFingerprint) && (
|
||||
<Box
|
||||
$direction="column"
|
||||
$gap="0.25rem"
|
||||
$margin={{ top: 'xs' }}
|
||||
$padding="sm"
|
||||
$background="#f5f5f5"
|
||||
$border="1px solid #ddd"
|
||||
$radius="4px"
|
||||
>
|
||||
{knownFingerprint && (
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<Text $size="xs" $weight="600" $variation="secondary">
|
||||
{t('Previously known:')}
|
||||
</Text>
|
||||
<Text
|
||||
$size="xs"
|
||||
style={{ fontFamily: 'monospace', letterSpacing: '0.05em' }}
|
||||
>
|
||||
{knownFingerprint}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{currentFingerprint && (
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<Text $size="xs" $weight="600" $variation="secondary">
|
||||
{t('Current key:')}
|
||||
</Text>
|
||||
<Text
|
||||
$size="xs"
|
||||
style={{ fontFamily: 'monospace', letterSpacing: '0.05em' }}
|
||||
>
|
||||
{currentFingerprint}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Badge } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import {
|
||||
QuickSearchItemContent,
|
||||
QuickSearchItemContentProps,
|
||||
} from '@/components/quick-search';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useKeyFingerprint } from '@/docs/doc-collaboration';
|
||||
import { User, UserAvatar } from '@/features/auth';
|
||||
|
||||
type Props = {
|
||||
@@ -11,6 +15,9 @@ type Props = {
|
||||
alwaysShowRight?: boolean;
|
||||
right?: QuickSearchItemContentProps['right'];
|
||||
isInvitation?: boolean;
|
||||
suffix?: string;
|
||||
onSuffixClick?: () => void;
|
||||
fingerprintKey?: string | null;
|
||||
};
|
||||
|
||||
export const SearchUserRow = ({
|
||||
@@ -18,9 +25,14 @@ export const SearchUserRow = ({
|
||||
right,
|
||||
alwaysShowRight = false,
|
||||
isInvitation = false,
|
||||
suffix,
|
||||
onSuffixClick,
|
||||
fingerprintKey,
|
||||
}: Props) => {
|
||||
const hasFullName = !!user.full_name;
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const fingerprint = useKeyFingerprint(fingerprintKey);
|
||||
|
||||
return (
|
||||
<QuickSearchItemContent
|
||||
@@ -38,14 +50,61 @@ export const SearchUserRow = ({
|
||||
background={isInvitation ? colorsTokens['gray-400'] : undefined}
|
||||
/>
|
||||
<Box $direction="column">
|
||||
<Text $size="sm" $weight="500">
|
||||
{hasFullName ? user.full_name : user.email}
|
||||
</Text>
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
|
||||
<Text $size="sm" $weight="500">
|
||||
{hasFullName ? user.full_name : user.email}
|
||||
</Text>
|
||||
{suffix && (
|
||||
<Text
|
||||
$size="xs"
|
||||
$weight="600"
|
||||
$color={colorsTokens['warning-600']}
|
||||
{...(onSuffixClick && {
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onSuffixClick();
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{suffix}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{hasFullName && (
|
||||
<Text $size="xs" $margin={{ top: '-2px' }} $variation="secondary">
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
{fingerprint && (
|
||||
<Badge
|
||||
style={{ width: 'fit-content', gap: '0.3rem', margin: '5px 0' }}
|
||||
>
|
||||
<Text
|
||||
$size="xs"
|
||||
$weight="600"
|
||||
$variation="secondary"
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
{t('Fingerprint')}{' '}
|
||||
</Text>
|
||||
<Text
|
||||
$size="xs"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '10px',
|
||||
}}
|
||||
>
|
||||
{fingerprint}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ export const DocTreeItemActions = ({
|
||||
|
||||
createChildDoc({
|
||||
parentId: doc.id,
|
||||
isEncrypted: false,
|
||||
});
|
||||
}}
|
||||
$theme="brand"
|
||||
|
||||
+1
@@ -92,6 +92,7 @@ export const ModalConfirmationVersion = ({
|
||||
updateDoc({
|
||||
id: docId,
|
||||
content: version.content,
|
||||
contentEncrypted: false,
|
||||
});
|
||||
|
||||
onClose();
|
||||
|
||||
@@ -74,6 +74,7 @@ export function useImportDoc(props?: UseImportDocOptions) {
|
||||
is_creator_me: isCreatorMe,
|
||||
title: undefined,
|
||||
is_favorite: undefined,
|
||||
is_encrypted: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import styled, { css } from 'styled-components';
|
||||
import AllDocs from '@/assets/icons/doc-all.svg';
|
||||
import { Box, Card, Icon, Text } from '@/components';
|
||||
import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management';
|
||||
import { useUserEncryption } from '@/features/docs/doc-collaboration';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useInfiniteDocsTrashbin } from '../api';
|
||||
@@ -79,7 +80,9 @@ export const DocsGrid = ({
|
||||
});
|
||||
}, [data?.pages]);
|
||||
|
||||
const loading = isFetching || isLoading;
|
||||
const { encryptionLoading, encryptionSettings } = useUserEncryption();
|
||||
|
||||
const loading = isFetching || isLoading || encryptionLoading;
|
||||
const hasDocs = data?.pages.some((page) => page.results.length > 0);
|
||||
const loadMore = (inView: boolean) => {
|
||||
if (!inView || loading) {
|
||||
|
||||
@@ -7,8 +7,10 @@ import { css } from 'styled-components';
|
||||
import { Box, Icon, StyledLink, Text } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { usePublicKeyRegistry } from '@/docs/doc-collaboration';
|
||||
import { Doc, LinkReach, SimpleDocItem } from '@/docs/doc-management';
|
||||
import { DocShareModal } from '@/docs/doc-share';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useDate } from '@/hooks';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
@@ -33,6 +35,11 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||
const { flexLeft, flexRight } = useResponsiveDocGrid();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const shareModal = useModal();
|
||||
const { user } = useAuth();
|
||||
const { hasMismatches: hasKeyWarning } = usePublicKeyRegistry(
|
||||
undefined,
|
||||
user?.id,
|
||||
);
|
||||
const isPublic = doc.link_reach === LinkReach.PUBLIC;
|
||||
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
|
||||
const isShared = isPublic || isAuthenticated;
|
||||
@@ -96,7 +103,11 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||
$padding={{ right: isDesktop ? 'md' : '3xs' }}
|
||||
$maxWidth="100%"
|
||||
>
|
||||
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
|
||||
<SimpleDocItem
|
||||
isPinned={doc.is_favorite}
|
||||
isEncrypted={doc.is_encrypted}
|
||||
doc={doc}
|
||||
/>
|
||||
{isShared && (
|
||||
<Box
|
||||
$padding={{ top: !isDesktop ? '4xs' : undefined }}
|
||||
@@ -177,6 +188,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||
doc={doc}
|
||||
handleClick={handleShareClick}
|
||||
disabled={isInTrashbin}
|
||||
hasKeyWarning={hasKeyWarning}
|
||||
/>
|
||||
)}
|
||||
{isInTrashbin ? (
|
||||
|
||||
+5
-3
@@ -8,11 +8,13 @@ type Props = {
|
||||
doc: Doc;
|
||||
handleClick: () => void;
|
||||
disabled: boolean;
|
||||
hasKeyWarning?: boolean;
|
||||
};
|
||||
export const DocsGridItemSharedButton = ({
|
||||
doc,
|
||||
handleClick,
|
||||
disabled,
|
||||
hasKeyWarning,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const sharedCount = doc.nb_accesses_direct;
|
||||
@@ -44,14 +46,14 @@ export const DocsGridItemSharedButton = ({
|
||||
event.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
color="brand"
|
||||
color={hasKeyWarning ? 'warning' : 'brand'}
|
||||
variant="secondary"
|
||||
size="nano"
|
||||
icon={
|
||||
<Icon
|
||||
$theme="brand"
|
||||
$theme={hasKeyWarning ? 'warning' : 'brand'}
|
||||
$variation="secondary"
|
||||
iconName="group"
|
||||
iconName={hasKeyWarning ? 'warning' : 'group'}
|
||||
disabled={disabled}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
@@ -175,6 +175,7 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
deleted_at: null,
|
||||
depth: 1,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
nb_accesses_direct: 1,
|
||||
nb_accesses_ancestors: 1,
|
||||
numchild: 0,
|
||||
|
||||
@@ -24,17 +24,19 @@ export const Skeleton = ({ children }: PropsWithChildren) => {
|
||||
const timeoutVisibleRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSkeletonVisible) {
|
||||
setIsVisible(true);
|
||||
setIsFadingOut(false);
|
||||
} else {
|
||||
setIsFadingOut(true);
|
||||
if (!timeoutVisibleRef.current) {
|
||||
timeoutVisibleRef.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, FADE_DURATION_MS * 2);
|
||||
}
|
||||
}
|
||||
setIsVisible(false);
|
||||
|
||||
// if (isSkeletonVisible) {
|
||||
// setIsVisible(true);
|
||||
// setIsFadingOut(false);
|
||||
// } else {
|
||||
// setIsFadingOut(true);
|
||||
// if (!timeoutVisibleRef.current) {
|
||||
// timeoutVisibleRef.current = setTimeout(() => {
|
||||
// setIsVisible(false);
|
||||
// }, FADE_DURATION_MS * 2);
|
||||
// }
|
||||
// }
|
||||
|
||||
return () => {
|
||||
if (timeoutVisibleRef.current) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user