mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
27 Commits
renovate/p
...
feat/e2ee-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e651190fd | ||
|
|
21e2658f61 | ||
|
|
a794bdf34d | ||
|
|
c9d09152fa | ||
|
|
e6403be62e | ||
|
|
ca3502ee4d | ||
|
|
8c5352103a | ||
|
|
3e3ee7e698 | ||
|
|
af1c40995b | ||
|
|
1da0f6600e | ||
|
|
a3fdb206ef | ||
|
|
da4d323144 | ||
|
|
3e45193a7c | ||
|
|
7a55e31a73 | ||
|
|
4baef38cae | ||
|
|
1eba8b77c0 | ||
|
|
579ff98a5a | ||
|
|
fe34b93249 | ||
|
|
205960106b | ||
|
|
d685b541c5 | ||
|
|
834ed4226f | ||
|
|
3f8e105035 | ||
|
|
431bec3970 | ||
|
|
54f2762e79 | ||
|
|
9c438eba06 | ||
|
|
bedb0573b8 | ||
|
|
9d3088d9db |
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@@ -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://encryption.localhost:7200/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://encryption.localhost:7200"
|
||||
],
|
||||
"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,36 @@ 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
|
||||
)
|
||||
is_pending_encryption_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 +96,11 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"is_pending_encryption_for_user",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -99,6 +114,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
@@ -107,8 +123,11 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"is_pending_encryption_for_user",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -151,6 +170,59 @@ 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
|
||||
|
||||
def get_is_pending_encryption_for_user(self, instance):
|
||||
"""True when the current user has a DocumentAccess row on this
|
||||
encrypted document with no wrapped key — i.e. they were added
|
||||
to the access list but haven't completed their encryption
|
||||
onboarding yet.
|
||||
|
||||
Clients use this to avoid attempting to decrypt (which would
|
||||
fail with a meaningless key error) and render a "waiting for
|
||||
acceptance" panel directly instead.
|
||||
"""
|
||||
if not instance.is_encrypted:
|
||||
return False
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return False
|
||||
return models.DocumentAccess.objects.filter(
|
||||
document=instance,
|
||||
user=request.user,
|
||||
encrypted_document_symmetric_key_for_user__isnull=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class DocumentLightSerializer(serializers.ModelSerializer):
|
||||
"""Minial document serializer for nesting in document accesses."""
|
||||
@@ -165,6 +237,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 +248,24 @@ 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",
|
||||
"is_pending_encryption_for_user",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -209,7 +288,10 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"is_pending_encryption_for_user",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -228,6 +310,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 +372,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 +438,14 @@ 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
|
||||
)
|
||||
# TODO: REQUIRED!!!
|
||||
encryption_public_key_fingerprint = serializers.CharField(
|
||||
required=False, allow_blank=True, max_length=16
|
||||
)
|
||||
is_pending_encryption = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.DocumentAccess
|
||||
@@ -357,6 +460,9 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"abilities",
|
||||
"max_ancestors_role",
|
||||
"max_role",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"encryption_public_key_fingerprint",
|
||||
"is_pending_encryption",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
@@ -364,8 +470,46 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"abilities",
|
||||
"max_ancestors_role",
|
||||
"max_role",
|
||||
"is_pending_encryption",
|
||||
]
|
||||
|
||||
def get_is_pending_encryption(self, instance):
|
||||
"""True when the parent document is encrypted but this access has
|
||||
no wrapped key — the user was added before completing their
|
||||
encryption onboarding. A validated collaborator must "accept"
|
||||
them (re-wrap the key) before they can decrypt.
|
||||
"""
|
||||
document = instance.document
|
||||
return bool(
|
||||
getattr(document, "is_encrypted", False)
|
||||
and instance.encrypted_document_symmetric_key_for_user is None
|
||||
)
|
||||
|
||||
def get_fields(self):
|
||||
"""Dynamically adjust encryption fields based on document state.
|
||||
|
||||
For encrypted documents the key is OPTIONAL at serializer level:
|
||||
the viewset decides whether omitting it is legitimate (invitee
|
||||
has no public key yet → access created pending) or a 400 (field
|
||||
provided against a non-encrypted document). For non-encrypted
|
||||
documents the field is hidden entirely.
|
||||
"""
|
||||
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
|
||||
|
||||
if (
|
||||
document
|
||||
and not getattr(document, "is_encrypted", False)
|
||||
and "encrypted_document_symmetric_key_for_user" in fields
|
||||
):
|
||||
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 +760,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 +771,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 +1017,126 @@ 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)
|
||||
# Value is either a base64 wrapped key (validated user) or explicit
|
||||
# null (user is on the access list but has no public key yet — access
|
||||
# row is created pending, to be "accepted" later by another validated
|
||||
# collaborator via PATCH /accesses/{id}/encryption-key/).
|
||||
encryptedSymmetricKeyPerUser = serializers.DictField(
|
||||
child=serializers.CharField(allow_null=True),
|
||||
required=True,
|
||||
help_text=(
|
||||
"Mapping of user OIDC sub → wrapped symmetric key (base64), "
|
||||
"or null to mark the user as pending their encryption "
|
||||
"onboarding. The caller's own sub must always be a wrapped "
|
||||
"key, never null."
|
||||
),
|
||||
)
|
||||
# Required: matched to the wrapped-key map. Every user sub present
|
||||
# in `encryptedSymmetricKeyPerUser` must also appear here with the
|
||||
# fingerprint of the public key used to wrap their copy (or null
|
||||
# for pending users with no public key yet). Stored on the access
|
||||
# row verbatim so clients can later tell which key each user's
|
||||
# wrapped key was produced for — used by the key-mismatch panel
|
||||
# to display "Fingerprint at the time it was shared with you".
|
||||
#
|
||||
# Not security-sensitive in the crypto sense — the actual wrap is
|
||||
# the wrapped key itself. The fingerprint is a display hint; a
|
||||
# malicious client could send wrong values but the worst it
|
||||
# achieves is confusing the user whose client was lying.
|
||||
encryptionPublicKeyFingerprintPerUser = serializers.DictField(
|
||||
child=serializers.CharField(
|
||||
allow_null=True, allow_blank=True, max_length=16
|
||||
),
|
||||
required=True,
|
||||
help_text=(
|
||||
"Mapping of user OIDC sub → fingerprint of their public key "
|
||||
"at encryption time. Must cover the same set of users as "
|
||||
"`encryptedSymmetricKeyPerUser`; null is valid for pending "
|
||||
"users."
|
||||
),
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class AcceptEncryptionAccessSerializer(serializers.Serializer):
|
||||
"""Payload for PATCH /accesses/{id}/encryption-key/ — "accept" a
|
||||
pending collaborator by re-wrapping the document's symmetric key
|
||||
against their (now-available) public key.
|
||||
"""
|
||||
|
||||
encrypted_document_symmetric_key_for_user = serializers.CharField(
|
||||
required=True,
|
||||
allow_null=False,
|
||||
allow_blank=False,
|
||||
help_text=(
|
||||
"Wrapped symmetric key for the pending user, base64-encoded. "
|
||||
"Null / empty is not allowed: this endpoint only flips "
|
||||
"pending → validated. To revert, delete the access row."
|
||||
),
|
||||
)
|
||||
encryption_public_key_fingerprint = serializers.CharField(
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
max_length=16,
|
||||
)
|
||||
|
||||
|
||||
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,250 @@ 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 we have encrypted symmetric keys for all users with access.
|
||||
# Keys in encryptedSymmetricKeyPerUser are keyed by the user's OIDC sub (suite_user_id).
|
||||
# Values may be a wrapped key (validated) or explicit null (pending —
|
||||
# user hasn't completed their encryption onboarding yet).
|
||||
document_accesses = models.DocumentAccess.objects.filter(
|
||||
document=document, user__isnull=False
|
||||
).select_related('user')
|
||||
|
||||
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 an entry (either a wrapped key or null) when encrypting.'
|
||||
})
|
||||
|
||||
# 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.'
|
||||
})
|
||||
|
||||
# The caller is the one performing the encryption — they must
|
||||
# hold the key. Explicit null for themselves is never legitimate.
|
||||
caller_sub = str(request.user.sub)
|
||||
if (
|
||||
caller_sub in encryptedSymmetricKeyPerUser
|
||||
and encryptedSymmetricKeyPerUser[caller_sub] is None
|
||||
):
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptedSymmetricKeyPerUser':
|
||||
'You cannot mark yourself as pending encryption onboarding — '
|
||||
'provide a wrapped key for your own user.'
|
||||
})
|
||||
|
||||
# Per-user fingerprint map — required, keyed on the same user
|
||||
# subs as the wrapped-key map. Stored verbatim on the access
|
||||
# row so clients can later tell which key each user's wrapped
|
||||
# key was produced for.
|
||||
fingerprint_per_user = serializer.validated_data[
|
||||
'encryptionPublicKeyFingerprintPerUser'
|
||||
]
|
||||
fingerprint_subs = set(fingerprint_per_user.keys())
|
||||
if fingerprint_subs != provided_user_ids:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptionPublicKeyFingerprintPerUser':
|
||||
'Must cover the same set of users as encryptedSymmetricKeyPerUser. '
|
||||
f'Missing: {provided_user_ids - fingerprint_subs}. '
|
||||
f'Extra: {fingerprint_subs - provided_user_ids}.'
|
||||
})
|
||||
|
||||
# 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 + fingerprints 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:
|
||||
access = models.DocumentAccess.objects.get(
|
||||
document=document, user__sub=sub,
|
||||
)
|
||||
access.encrypted_document_symmetric_key_for_user = encrypted_key
|
||||
access.encryption_public_key_fingerprint = (
|
||||
fingerprint_per_user.get(sub) or None
|
||||
)
|
||||
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 +2406,31 @@ class DocumentAccessViewSet(
|
||||
"Only owners of a document can assign other users as owners."
|
||||
)
|
||||
|
||||
# Handle encrypted_document_symmetric_key_for_user during
|
||||
# creation. For encrypted documents the key is OPTIONAL: if the
|
||||
# invitee has no public key yet (pending onboarding) the caller
|
||||
# legitimately has nothing to wrap. The access row is then
|
||||
# created pending (key column NULL) and can be "accepted" later
|
||||
# via PATCH /accesses/{id}/encryption-key/. Whether the invitee
|
||||
# actually has a public key is a client-side concern — the
|
||||
# backend only enforces "key provided ⇒ document must be encrypted".
|
||||
if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data:
|
||||
key_value = serializer.validated_data[
|
||||
'encrypted_document_symmetric_key_for_user'
|
||||
]
|
||||
if key_value and 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.'
|
||||
})
|
||||
# Normalise "" → None so the DB row uses NULL consistently
|
||||
# and `is_pending_encryption` (which tests IS NULL) is
|
||||
# reliable downstream.
|
||||
if not key_value:
|
||||
serializer.validated_data[
|
||||
'encrypted_document_symmetric_key_for_user'
|
||||
] = None
|
||||
|
||||
access = serializer.save(document_id=self.kwargs["resource_id"])
|
||||
|
||||
if access.user:
|
||||
@@ -2112,6 +2445,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
|
||||
@@ -2125,6 +2466,13 @@ class DocumentAccessViewSet(
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete an access to the document and notify the collaboration server."""
|
||||
# Strand-prevention: on an encrypted document, removing the last
|
||||
# access row that holds a wrapped key while other rows are
|
||||
# pending (`encrypted_document_symmetric_key_for_user IS NULL`)
|
||||
# would leave the document undecryptable by anyone — nobody
|
||||
# could "accept" the pending users afterwards.
|
||||
self._raise_if_would_strand_pending_users(instance)
|
||||
|
||||
instance.delete()
|
||||
|
||||
# Notify collaboration server about the access removed
|
||||
@@ -2132,6 +2480,123 @@ class DocumentAccessViewSet(
|
||||
str(instance.document.id), str(instance.user.id)
|
||||
)
|
||||
|
||||
def _raise_if_would_strand_pending_users(self, instance):
|
||||
"""Reject delete if it would leave pending users with nobody
|
||||
able to accept them. See the docstring in `perform_destroy`.
|
||||
"""
|
||||
document = instance.document
|
||||
if not getattr(document, "is_encrypted", False):
|
||||
return
|
||||
# Removing a row that's itself pending never strands anyone.
|
||||
if not instance.encrypted_document_symmetric_key_for_user:
|
||||
return
|
||||
|
||||
other_accesses = models.DocumentAccess.objects.filter(
|
||||
document=document
|
||||
).exclude(pk=instance.pk)
|
||||
remaining_validated = (
|
||||
other_accesses.filter(
|
||||
encrypted_document_symmetric_key_for_user__isnull=False,
|
||||
)
|
||||
.exclude(encrypted_document_symmetric_key_for_user="")
|
||||
.exists()
|
||||
)
|
||||
has_pending = other_accesses.filter(
|
||||
encrypted_document_symmetric_key_for_user__isnull=True,
|
||||
).exists()
|
||||
|
||||
if has_pending and not remaining_validated:
|
||||
raise drf.exceptions.ValidationError({
|
||||
"detail": (
|
||||
"Removing this user would leave pending collaborators "
|
||||
"unable to decrypt the document. Either wait for them "
|
||||
"to finish their encryption onboarding, or remove "
|
||||
"encryption from the document first."
|
||||
),
|
||||
"code": "would_strand_pending_users",
|
||||
})
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True, methods=["patch"], url_path="encryption-key"
|
||||
)
|
||||
def encryption_key(self, request, *args, **kwargs):
|
||||
"""Accept a pending collaborator by re-wrapping the document's
|
||||
symmetric key against their public key.
|
||||
|
||||
Strictly pending → validated. To revoke a user, delete the access
|
||||
row instead. The viewset-level permission already enforces that
|
||||
the caller is a privileged user on the document (admin/owner);
|
||||
here we additionally require the caller to currently hold a
|
||||
wrapped key themselves — without that they have no plaintext
|
||||
subtree key to re-wrap from.
|
||||
"""
|
||||
access = self.get_object()
|
||||
document = access.document
|
||||
|
||||
if not getattr(document, "is_encrypted", False):
|
||||
return drf.response.Response(
|
||||
{"detail": "Document is not encrypted."},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if access.encrypted_document_symmetric_key_for_user:
|
||||
return drf.response.Response(
|
||||
{
|
||||
"detail": (
|
||||
"This access is not pending encryption onboarding. "
|
||||
"Delete the access row instead if you want to "
|
||||
"revoke it."
|
||||
),
|
||||
"code": "access_not_pending",
|
||||
},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
caller_has_key = models.DocumentAccess.objects.filter(
|
||||
document=document,
|
||||
user=request.user,
|
||||
encrypted_document_symmetric_key_for_user__isnull=False,
|
||||
).exclude(encrypted_document_symmetric_key_for_user="").exists()
|
||||
if not caller_has_key:
|
||||
return drf.response.Response(
|
||||
{
|
||||
"detail": (
|
||||
"You do not currently hold a decryption key for "
|
||||
"this document, so you cannot accept another "
|
||||
"user on it."
|
||||
),
|
||||
},
|
||||
status=drf.status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = serializers.AcceptEncryptionAccessSerializer(
|
||||
data=request.data
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
access.encrypted_document_symmetric_key_for_user = (
|
||||
serializer.validated_data[
|
||||
"encrypted_document_symmetric_key_for_user"
|
||||
]
|
||||
)
|
||||
access.encryption_public_key_fingerprint = (
|
||||
serializer.validated_data["encryption_public_key_fingerprint"]
|
||||
)
|
||||
access.save(
|
||||
update_fields=[
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"encryption_public_key_fingerprint",
|
||||
]
|
||||
)
|
||||
|
||||
CollaborationService().reset_connections(
|
||||
str(document.id),
|
||||
str(access.user.id) if access.user else None,
|
||||
)
|
||||
|
||||
output = self.get_serializer(access)
|
||||
return drf.response.Response(output.data)
|
||||
|
||||
|
||||
class InvitationViewset(
|
||||
drf.mixins.CreateModelMixin,
|
||||
@@ -2221,6 +2686,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 +2704,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,
|
||||
@@ -1176,12 +1229,21 @@ class DocumentAccess(BaseAccess):
|
||||
if len(set_role_to) == 1:
|
||||
set_role_to = []
|
||||
|
||||
# "encryption_key" gates the PATCH
|
||||
# /accesses/{id}/encryption-key/ Accept endpoint. The viewset
|
||||
# additionally enforces that the caller holds a wrapped key on
|
||||
# the document (otherwise they have nothing to re-wrap), so at
|
||||
# this layer the rule just mirrors "can manage accesses on
|
||||
# this document" — same privileged-role check as update, minus
|
||||
# the role-change prerequisites which aren't relevant when
|
||||
# re-wrapping a key.
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": bool(set_role_to) and is_owner_or_admin,
|
||||
"partial_update": bool(set_role_to) and is_owner_or_admin,
|
||||
"retrieve": (self.user and self.user.id == user.id) or is_owner_or_admin,
|
||||
"set_role_to": set_role_to,
|
||||
"encryption_key": is_owner_or_admin,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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://data.encryption.localhost:7200
|
||||
NEXT_PUBLIC_INTERFACE_URL=http://encryption.localhost:7200
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,129 @@
|
||||
/**
|
||||
* 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;
|
||||
protected readonly onDecryptError?: (err: unknown) => 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);
|
||||
this.onDecryptError?.(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;
|
||||
onDecryptError?: (err: unknown) => void;
|
||||
}) {
|
||||
return class extends EncryptedWebSocket {
|
||||
protected readonly vaultClient = options.vaultClient;
|
||||
protected readonly encryptedSymmetricKey = options.encryptedSymmetricKey;
|
||||
protected readonly onSystemMessage = options.onSystemMessage;
|
||||
protected readonly onDecryptError = options.onDecryptError;
|
||||
};
|
||||
}
|
||||
@@ -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,126 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useVaultClient } from '../vault';
|
||||
|
||||
/**
|
||||
* 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 { client: vaultClient } = useVaultClient();
|
||||
const [fingerprint, setFingerprint] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!base64Key || !vaultClient) {
|
||||
setFingerprint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const raw = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0));
|
||||
vaultClient.computeKeyFingerprint(raw.buffer).then((fp) => {
|
||||
if (!cancelled) {
|
||||
setFingerprint(vaultClient.formatFingerprint(fp));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [base64Key, vaultClient]);
|
||||
|
||||
return fingerprint;
|
||||
}
|
||||
@@ -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,29 @@
|
||||
export {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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: isReady ? clientRef.current : null,
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
hasKeys,
|
||||
publicKey,
|
||||
refreshKeyState,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</VaultClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useVaultClient = (): VaultClientContextValue =>
|
||||
useContext(VaultClientContext);
|
||||
44
src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/global.d.ts
vendored
Normal file
44
src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/global.d.ts
vendored
Normal file
@@ -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 }>;
|
||||
encryptWithoutKey(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 }>;
|
||||
shareKeys(encryptedSymmetricKey: ArrayBuffer, userPublicKeys: Record<string, ArrayBuffer>): Promise<{ encryptedKeys: Record<string, ArrayBuffer> }>;
|
||||
computeKeyFingerprint(publicKey: ArrayBuffer): Promise<string>;
|
||||
formatFingerprint(fingerprint: string): string;
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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'],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
);
|
||||
@@ -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}
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
);
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './AccessibleImageBlock';
|
||||
export * from './AudioBlock';
|
||||
export * from './CalloutBlock';
|
||||
export * from './PdfBlock';
|
||||
export * from './UploadLoaderBlock';
|
||||
export * from './VideoBlock';
|
||||
|
||||
@@ -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,72 @@
|
||||
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>;
|
||||
encryptedSymmetricKeyPerUser: Record<string, string | null>;
|
||||
encryptionPublicKeyFingerprintPerUser: Record<string, string | null>;
|
||||
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,
|
||||
encryptionPublicKeyFingerprintPerUser:
|
||||
params.encryptionPublicKeyFingerprintPerUser,
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
import type { Doc } from '../types';
|
||||
|
||||
/**
|
||||
* "Wrong secret key for the given ciphertext" from the vault means the
|
||||
* document was encrypted against a PREVIOUS public key of the current
|
||||
* user (reset, different device without backup restore, etc.). The
|
||||
* ciphertext itself is still valid for whoever holds the old key, but
|
||||
* this user's current key can't unwrap it. The fix is social: someone
|
||||
* with access has to re-share the doc so the symmetric key gets
|
||||
* wrapped against their CURRENT public key.
|
||||
*/
|
||||
export const isWrongSecretKeyError = (
|
||||
err: Error | null | undefined,
|
||||
): boolean => {
|
||||
if (!err) return false;
|
||||
const msg = err.message?.toLowerCase() ?? '';
|
||||
return msg.includes('wrong secret key');
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The doc — used to read the share-time fingerprint from
|
||||
* `doc.accesses_fingerprints_per_user[currentUser.suite_user_id]`.
|
||||
* Docs exposes this as a per-user map on the document. (We may
|
||||
* later collapse it to a single `encryption_public_key_fingerprint_for_user`
|
||||
* scalar once we take the same "current user only" approach Drive
|
||||
* does, but keeping it as a map for now matches the existing API.)
|
||||
*/
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly panel shown when the page / websocket surfaces a "wrong
|
||||
* secret key" decryption failure. Explains the key rotation, shows the
|
||||
* share-time fingerprint (from the doc's fingerprint map) AND the
|
||||
* user's current key fingerprint so whoever re-shares can verify
|
||||
* they're wrapping against the key the user actually holds now.
|
||||
*/
|
||||
export const KeyMismatchPanel = ({ doc }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const [currentFingerprint, setCurrentFingerprint] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const shareTimeFingerprint = user?.suite_user_id
|
||||
? (doc.accesses_fingerprints_per_user?.[user.suite_user_id] ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { publicKey } = await vaultClient.getPublicKey();
|
||||
const raw = await vaultClient.computeKeyFingerprint(publicKey);
|
||||
const formatted = vaultClient.formatFingerprint(raw);
|
||||
if (!cancelled) setCurrentFingerprint(formatted);
|
||||
} catch {
|
||||
// Ignore — we just won't render the fingerprint row.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [vaultClient]);
|
||||
|
||||
const formattedShareTime = (() => {
|
||||
if (!shareTimeFingerprint) return null;
|
||||
if (!vaultClient) return shareTimeFingerprint;
|
||||
try {
|
||||
return vaultClient.formatFingerprint(shareTimeFingerprint);
|
||||
} catch {
|
||||
return shareTimeFingerprint;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box $align="center" $margin="auto" $gap="md" $padding="2rem">
|
||||
<Icon iconName="key_off" $size="3rem" $theme="warning" />
|
||||
<Text as="h2" $textAlign="center" $margin="0">
|
||||
{t('This document was encrypted with a different key')}
|
||||
</Text>
|
||||
<Box $maxWidth="500px" $gap="sm">
|
||||
<Text $variation="secondary" $textAlign="center">
|
||||
{t(
|
||||
"The document was encrypted for you at a time when you were using a different encryption key — possibly before you reset your keys or switched device without restoring a backup. Your current key can no longer decrypt it. Ask an owner or administrator of this document to remove you from the access list and add you back so it gets re-encrypted for your current key.",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
{(formattedShareTime || currentFingerprint) && (
|
||||
<Box $gap="2xs" $maxWidth="500px" $align="center">
|
||||
{formattedShareTime && (
|
||||
<Text $variation="secondary" $size="sm" $textAlign="center">
|
||||
{t('Fingerprint at the time it was shared with you:')}{' '}
|
||||
<Text
|
||||
as="span"
|
||||
$css={`
|
||||
font-family: monospace;
|
||||
background: var(--c--theme--colors--greyscale-100, #f4f4f5);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
`}
|
||||
>
|
||||
{formattedShareTime}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
{currentFingerprint && (
|
||||
<Text $variation="secondary" $size="sm" $textAlign="center">
|
||||
{t('Your current key fingerprint:')}{' '}
|
||||
<Text
|
||||
as="span"
|
||||
$css={`
|
||||
font-family: monospace;
|
||||
background: var(--c--theme--colors--greyscale-100, #f4f4f5);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
`}
|
||||
>
|
||||
{currentFingerprint}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,509 @@
|
||||
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;
|
||||
|
||||
// Members with no public key will be written to the backend as
|
||||
// pending (`null` wrapped key). They'll see the document in their
|
||||
// listings but won't be able to decrypt until a validated collaborator
|
||||
// accepts them from the share dialog. This no longer blocks
|
||||
// encryption — only the degenerate case where NOBODY has a key does.
|
||||
const hasAnyPublicKey =
|
||||
accesses === undefined || accesses.length === 0
|
||||
? true
|
||||
: accesses.some(
|
||||
(a) =>
|
||||
a.user?.suite_user_id && !!publicKeysMap[a.user.suite_user_id],
|
||||
);
|
||||
|
||||
const canEncrypt =
|
||||
hasEncryptionKeys &&
|
||||
isRestricted &&
|
||||
!hasPendingInvitations &&
|
||||
hasAnyPublicKey;
|
||||
|
||||
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.encryptWithoutKey(
|
||||
ongoingDocState.buffer as ArrayBuffer,
|
||||
publicKeysMap,
|
||||
);
|
||||
|
||||
// Contract with /encrypt/: every user on the access list must
|
||||
// appear in the payload exactly once. Validated users get their
|
||||
// base64 wrapped key; members without a public key yet get
|
||||
// explicit null (pending onboarding — they'll be accepted later).
|
||||
const encryptedSymmetricKeyPerUser: Record<string, string | null> = {};
|
||||
|
||||
for (const [uid, keyBuffer] of Object.entries(encryptedKeys)) {
|
||||
encryptedSymmetricKeyPerUser[uid] = toBase64(new Uint8Array(keyBuffer));
|
||||
}
|
||||
for (const access of membersWithoutKey) {
|
||||
const sub = access.user?.suite_user_id;
|
||||
if (sub && !(sub in encryptedSymmetricKeyPerUser)) {
|
||||
encryptedSymmetricKeyPerUser[sub] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Matched fingerprint map — same set of users, same cardinality.
|
||||
// Stored on each DocumentAccess row so the key-mismatch panel can
|
||||
// later show users which historical key the doc was encrypted for.
|
||||
const encryptionPublicKeyFingerprintPerUser: Record<
|
||||
string,
|
||||
string | null
|
||||
> = {};
|
||||
for (const [uid, publicKey] of Object.entries(publicKeysMap)) {
|
||||
try {
|
||||
encryptionPublicKeyFingerprintPerUser[uid] =
|
||||
await vaultClient.computeKeyFingerprint(publicKey);
|
||||
} catch (err) {
|
||||
console.warn('[encrypt] computeKeyFingerprint failed for', uid, err);
|
||||
encryptionPublicKeyFingerprintPerUser[uid] = null;
|
||||
}
|
||||
}
|
||||
for (const access of membersWithoutKey) {
|
||||
const sub = access.user?.suite_user_id;
|
||||
if (sub && !(sub in encryptionPublicKeyFingerprintPerUser)) {
|
||||
encryptionPublicKeyFingerprintPerUser[sub] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
encryptionPublicKeyFingerprintPerUser,
|
||||
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'
|
||||
: 'hourglass_empty'
|
||||
}
|
||||
$size="sm"
|
||||
$theme={
|
||||
membersWithoutKey.length === 0 ? 'success' : 'warning'
|
||||
}
|
||||
/>
|
||||
<Text $size="sm">
|
||||
{membersWithoutKey.length === 0
|
||||
? t('All members have encryption enabled')
|
||||
: t(
|
||||
'{{count}} member(s) haven’t completed encryption onboarding yet. They will be added as pending and won’t be able to decrypt the document until another validated collaborator accepts them from the share dialog.',
|
||||
{ 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const useCreateChildDocTree = (parentId?: string) => {
|
||||
|
||||
createChildDoc({
|
||||
parentId,
|
||||
isEncrypted: false,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,20 +3,46 @@ 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;
|
||||
decryptionFailed: boolean;
|
||||
resetLostConnection: () => void;
|
||||
}
|
||||
|
||||
@@ -26,48 +52,110 @@ const defaultValues = {
|
||||
isReady: false,
|
||||
isSynced: false,
|
||||
hasLostConnection: false,
|
||||
encryptionTransition: null,
|
||||
decryptionFailed: false,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
onDecryptError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/wrong secret key/i.test(msg)) {
|
||||
set({ decryptionFailed: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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 +163,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 +247,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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Access {
|
||||
max_role: Role;
|
||||
team: string;
|
||||
user: User;
|
||||
is_pending_encryption?: boolean;
|
||||
document: {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -60,6 +61,8 @@ export interface Doc {
|
||||
depth: number;
|
||||
path: string;
|
||||
is_favorite: boolean;
|
||||
is_encrypted: boolean;
|
||||
is_pending_encryption_for_user?: boolean;
|
||||
link_reach: LinkReach;
|
||||
link_role?: LinkRole;
|
||||
nb_accesses_direct: number;
|
||||
@@ -71,6 +74,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();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
|
||||
|
||||
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
|
||||
|
||||
interface AcceptEncryptionAccessParams {
|
||||
docId: Doc['id'];
|
||||
accessId: string;
|
||||
encrypted_document_symmetric_key_for_user: string;
|
||||
encryption_public_key_fingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1.0/documents/{docId}/accesses/{accessId}/encryption-key/
|
||||
*
|
||||
* "Accept" a pending collaborator — the caller (who already holds a
|
||||
* wrapped symmetric key on the document) re-wraps it for a user whose
|
||||
* access row was created pending (they had no public key at invite
|
||||
* time). Flips `encrypted_document_symmetric_key_for_user` from NULL
|
||||
* to the supplied wrapped key and stores the current fingerprint.
|
||||
*/
|
||||
export const acceptEncryptionAccess = async ({
|
||||
docId,
|
||||
accessId,
|
||||
encrypted_document_symmetric_key_for_user,
|
||||
encryption_public_key_fingerprint,
|
||||
}: AcceptEncryptionAccessParams): Promise<void> => {
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/accesses/${accessId}/encryption-key/`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
encrypted_document_symmetric_key_for_user,
|
||||
encryption_public_key_fingerprint,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to accept the pending collaborator.',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function useAcceptEncryptionAccess() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, APIError, AcceptEncryptionAccessParams>({
|
||||
mutationFn: acceptEncryptionAccess,
|
||||
onSuccess: (_data, variables) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC_ACCESSES, { docId: variables.docId }],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_DOC, { docId: variables.docId }],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -20,18 +20,26 @@ interface CreateDocAccessParams {
|
||||
role: Role;
|
||||
docId: Doc['id'];
|
||||
memberId: User['id'];
|
||||
memberEncryptedSymmetricKey: string | null;
|
||||
encryptionPublicKeyFingerprint?: string | null;
|
||||
}
|
||||
|
||||
export const createDocAccess = async ({
|
||||
memberId,
|
||||
role,
|
||||
docId,
|
||||
memberEncryptedSymmetricKey,
|
||||
encryptionPublicKeyFingerprint,
|
||||
}: 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,
|
||||
...(encryptionPublicKeyFingerprint && {
|
||||
encryption_public_key_fingerprint: encryptionPublicKeyFingerprint,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,61 @@ 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;
|
||||
let encryptionPublicKeyFingerprint: string | null = null;
|
||||
|
||||
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
|
||||
const userPublicKey = user.suite_user_id ? publicKeysMap[user.suite_user_id] : undefined;
|
||||
|
||||
if (userPublicKey && user.suite_user_id) {
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
{ [user.suite_user_id]: userPublicKey },
|
||||
);
|
||||
|
||||
const wrappedKey = encryptedKeys[user.suite_user_id];
|
||||
if (wrappedKey) {
|
||||
memberEncryptedSymmetricKey = toBase64(new Uint8Array(wrappedKey));
|
||||
}
|
||||
|
||||
// Store the recipient's public key fingerprint at share time
|
||||
encryptionPublicKeyFingerprint =
|
||||
await vaultClient.computeKeyFingerprint(userPublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
return createDocAccess({
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
memberEncryptedSymmetricKey,
|
||||
encryptionPublicKeyFingerprint,
|
||||
});
|
||||
});
|
||||
|
||||
const settledPromises = await Promise.allSettled(promises);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -31,12 +45,14 @@ import {
|
||||
QuickSearchGroupAccessRequest,
|
||||
} from './DocShareAccessRequest';
|
||||
import { DocShareAddMemberList } from './DocShareAddMemberList';
|
||||
import { PendingEncryptionSection } from './PendingEncryptionSection';
|
||||
import {
|
||||
DocShareModalInviteUserRow,
|
||||
QuickSearchGroupInvitation,
|
||||
} 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 +65,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 +267,237 @@ 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}
|
||||
{doc.is_encrypted && canShare && membersQuery && (
|
||||
<PendingEncryptionSection
|
||||
doc={doc}
|
||||
accesses={membersQuery}
|
||||
documentEncryptionSettings={effectiveEncryptionSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 +508,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 +544,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
import type { Access, Doc } from '@/features/docs/doc-management';
|
||||
import type { DocumentEncryptionSettings } from '@/features/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
|
||||
import { useAcceptEncryptionAccess } from '../api/useAcceptEncryptionAccess';
|
||||
|
||||
interface Props {
|
||||
doc: Doc;
|
||||
accesses: Access[];
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists users who were added to an encrypted doc before completing
|
||||
* their encryption onboarding (`is_pending_encryption`).
|
||||
*
|
||||
* Two sub-states per row, driven by an upfront public-key probe:
|
||||
* - invitee HAS a public key → Accept button actionable. One click
|
||||
* re-wraps the document key against their key and PATCHes the row.
|
||||
* - invitee has NO public key yet → no button, just a hint saying we're
|
||||
* waiting for them to complete onboarding. This prevents the
|
||||
* "click Accept, get a cryptic error" loop.
|
||||
*/
|
||||
export const PendingEncryptionSection = ({
|
||||
doc,
|
||||
accesses,
|
||||
documentEncryptionSettings,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const { mutateAsync: acceptMutation } = useAcceptEncryptionAccess();
|
||||
|
||||
const [inFlight, setInFlight] = useState<Set<string>>(new Set());
|
||||
const [errorByAccessId, setErrorByAccessId] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [hasPublicKeyBySub, setHasPublicKeyBySub] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [probing, setProbing] = useState(true);
|
||||
|
||||
const pending = useMemo(
|
||||
() =>
|
||||
accesses.filter(
|
||||
(a) => a.is_pending_encryption && a.document.id === doc.id,
|
||||
),
|
||||
[accesses, doc.id],
|
||||
);
|
||||
const pendingSubsSignature = useMemo(
|
||||
() =>
|
||||
pending
|
||||
.map((a) => a.user?.suite_user_id)
|
||||
.filter((s): s is string => !!s)
|
||||
.sort()
|
||||
.join(','),
|
||||
[pending],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pending.length === 0 || !vaultClient) {
|
||||
setProbing(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setProbing(true);
|
||||
const subs = pending
|
||||
.map((a) => a.user?.suite_user_id)
|
||||
.filter((s): s is string => !!s);
|
||||
if (subs.length === 0) {
|
||||
setProbing(false);
|
||||
return;
|
||||
}
|
||||
vaultClient
|
||||
.fetchPublicKeys(subs)
|
||||
.then(({ publicKeys }) => {
|
||||
if (cancelled) return;
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const sub of subs) {
|
||||
next[sub] = !!publicKeys[sub];
|
||||
}
|
||||
setHasPublicKeyBySub(next);
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave empty — fall back to "waiting for their onboarding" */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setProbing(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// pendingSubsSignature intentionally used to avoid re-probing on
|
||||
// unrelated access array identity changes.
|
||||
}, [pendingSubsSignature, vaultClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
const handleAccept = async (access: Access) => {
|
||||
const sub = access.user?.suite_user_id;
|
||||
if (!sub || !vaultClient || !documentEncryptionSettings) return;
|
||||
setInFlight((prev) => new Set(prev).add(access.id));
|
||||
setErrorByAccessId((prev) => {
|
||||
const copy = { ...prev };
|
||||
delete copy[access.id];
|
||||
return copy;
|
||||
});
|
||||
try {
|
||||
const { publicKeys } = await vaultClient.fetchPublicKeys([sub]);
|
||||
const userPublicKey = publicKeys[sub];
|
||||
if (!userPublicKey) {
|
||||
setHasPublicKeyBySub((m) => ({ ...m, [sub]: false }));
|
||||
throw new Error(
|
||||
t("This user still hasn't completed their encryption onboarding."),
|
||||
);
|
||||
}
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
{ [sub]: userPublicKey },
|
||||
);
|
||||
const wrappedKey = encryptedKeys[sub];
|
||||
if (!wrappedKey) {
|
||||
throw new Error(t('Failed to wrap the document key for this user.'));
|
||||
}
|
||||
const fingerprint = await vaultClient.computeKeyFingerprint(userPublicKey);
|
||||
await acceptMutation({
|
||||
docId: doc.id,
|
||||
accessId: access.id,
|
||||
encrypted_document_symmetric_key_for_user: toBase64(
|
||||
new Uint8Array(wrappedKey),
|
||||
),
|
||||
encryption_public_key_fingerprint: fingerprint,
|
||||
});
|
||||
} catch (err) {
|
||||
setErrorByAccessId((prev) => ({
|
||||
...prev,
|
||||
[access.id]: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
} finally {
|
||||
setInFlight((prev) => {
|
||||
const copy = new Set(prev);
|
||||
copy.delete(access.id);
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="sm"
|
||||
$margin={{ horizontal: 'base', bottom: 'sm' }}
|
||||
$gap="xs"
|
||||
$css={`
|
||||
background: var(--c--theme--colors--warning-050, #fffbf0);
|
||||
border: 1px solid var(--c--theme--colors--warning-300, #ffd591);
|
||||
border-radius: 4px;
|
||||
`}
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon iconName="hourglass_empty" $size="sm" $theme="warning" />
|
||||
<Text $weight="600" $size="sm">
|
||||
{t('Users pending encryption access ({{count}})', {
|
||||
count: pending.length,
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box $gap="2xs">
|
||||
{pending.map((access) => {
|
||||
const sub = access.user?.suite_user_id;
|
||||
const isBusy = inFlight.has(access.id);
|
||||
const error = errorByAccessId[access.id];
|
||||
const hasPublicKey = sub ? hasPublicKeyBySub[sub] : false;
|
||||
const canAccept =
|
||||
!!sub &&
|
||||
!!documentEncryptionSettings &&
|
||||
hasPublicKey === true &&
|
||||
!probing;
|
||||
return (
|
||||
<Box
|
||||
key={access.id}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="xs"
|
||||
$wrap="wrap"
|
||||
$padding={{ vertical: '3xs' }}
|
||||
>
|
||||
<Box $flex={1} $minWidth="0">
|
||||
<Text $weight="500" $size="sm">
|
||||
{access.user?.full_name || access.user?.email}
|
||||
</Text>
|
||||
{access.user?.email && access.user?.full_name && (
|
||||
<Text $size="xs" $variation="secondary">
|
||||
{access.user.email}
|
||||
</Text>
|
||||
)}
|
||||
{!canAccept && !probing && (
|
||||
<Text $size="xs" $variation="secondary">
|
||||
{t(
|
||||
"Waiting for this user to complete their encryption onboarding. You'll be able to accept them once they have.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{error && (
|
||||
<Text $size="xs" $theme="error">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{canAccept && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => void handleAccept(access)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isBusy ? t('Accepting…') : t('Accept')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user