Compare commits

...

27 Commits

Author SHA1 Message Date
Thomas Ramé
5e651190fd wip pending encryption 2026-04-22 15:45:30 +02:00
Thomas Ramé
21e2658f61 wip 2026-04-16 18:36:18 +02:00
Thomas Ramé
a794bdf34d working 2026-03-27 19:20:57 +01:00
Thomas Ramé
c9d09152fa wip 2026-03-25 19:27:57 +01:00
Thomas Ramé
e6403be62e wip 2026-03-25 19:17:36 +01:00
Thomas Ramé
ca3502ee4d wip 2026-03-25 09:51:07 +01:00
Thomas Ramé
8c5352103a WARNING TO BRAINSTORM ON PROPAGATING AUTH TO IFRAME DOMAIN (separate flow with its own OIDC clientId or using current token?) 2026-03-24 15:02:19 +01:00
Thomas Ramé
3e3ee7e698 wip 2026-03-24 15:01:31 +01:00
Thomas Ramé
af1c40995b wip full settings flow for onboarding and removing encryption 2026-03-09 17:45:57 +01:00
Thomas Ramé
1da0f6600e wip fix preview of audio and video files 2026-03-06 19:18:04 +01:00
Thomas Ramé
a3fdb206ef wip broadcast encryption transition to adapt ui smoothly 2026-03-06 19:06:24 +01:00
Thomas Ramé
da4d323144 wip provider server command endpoints 2026-03-05 12:11:26 +01:00
Thomas Ramé
3e45193a7c wip prevent some search indexing for encrypted docs 2026-03-05 10:50:00 +01:00
Thomas Ramé
7a55e31a73 wip use a context instead for global usage 2026-03-05 10:26:34 +01:00
Thomas Ramé
4baef38cae wip allow revealing all medias at once 2026-03-05 00:46:54 +01:00
Thomas Ramé
1eba8b77c0 wip ui 2026-03-05 00:46:40 +01:00
Thomas Ramé
579ff98a5a wip encryption requirements 2026-03-04 23:56:09 +01:00
Thomas Ramé
fe34b93249 wip encryption issue to access doc 2026-03-04 23:38:35 +01:00
Thomas Ramé
205960106b wip improve accesses tips 2026-03-04 23:22:22 +01:00
Thomas Ramé
d685b541c5 wip block placeholders to save resources when encrypted 2026-03-04 19:58:32 +01:00
Thomas Ramé
834ed4226f wip decryption with attachments 2026-03-03 18:21:31 +01:00
Thomas Ramé
3f8e105035 wip encrypt with attachments 2026-03-03 17:45:56 +01:00
Thomas Ramé
431bec3970 wip manage encrypted attachments 2026-03-03 10:09:12 +01:00
Thomas Ramé
54f2762e79 wip save ok 2026-03-02 17:55:54 +01:00
Thomas Ramé
9c438eba06 wip 2026-03-02 16:52:31 +01:00
Thomas Ramé
bedb0573b8 just before trying standard server but with no in-memory y.doc 2026-02-10 17:55:30 +01:00
Thomas Ramé
9d3088d9db TO REMOVE: 2026-02-09 16:30:27 +01:00
118 changed files with 7275 additions and 828 deletions

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.21.1

1
.tool-versions Normal file
View File

@@ -0,0 +1 @@
nodejs 22.21.1

View File

@@ -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": [

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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."""

View File

@@ -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,

View File

@@ -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'),
),
]

View File

@@ -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",
),
),
]

View File

@@ -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",
),
]

View File

@@ -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,
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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):
"""

View File

@@ -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,

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}}
/>
)}
</>
);
};

View File

@@ -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 = () => {

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1,3 +1,6 @@
export * from './AccountMenu';
export * from './Auth';
export * from './ButtonLogin';
export * from './ModalEncryptionOnboarding';
export * from './ModalEncryptionSettings';
export * from './UserAvatar';

View File

@@ -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);

View File

@@ -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;
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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';

View File

@@ -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,
};
}
}

View File

@@ -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);

View 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;
};
}
}

View File

@@ -0,0 +1,2 @@
export { VaultClientProvider, useVaultClient } from './VaultClientProvider';
export type { VaultClientContextValue } from './VaultClientProvider';

View File

@@ -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}
/>,
);

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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'],
}),
);

View 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'],
}),
);

View 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}

View File

@@ -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'],
}),
);

View File

@@ -1,4 +1,6 @@
export * from './AccessibleImageBlock';
export * from './AudioBlock';
export * from './CalloutBlock';
export * from './PdfBlock';
export * from './UploadLoaderBlock';
export * from './VideoBlock';

View File

@@ -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,
});

View File

@@ -1,3 +1,4 @@
export * from './useDecryptMedia';
export * from './useHeadings';
export * from './useSaveDoc';
export * from './useShortcuts';

View File

@@ -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,
};
};

View File

@@ -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);
};

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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()}

View File

@@ -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';

View File

@@ -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,
}),
});

View File

@@ -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) {

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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);
}
},
});
};

View File

@@ -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);
}
},
});
};

View File

@@ -18,6 +18,7 @@ export type SubDocsParams = {
is_creator_me?: boolean;
title?: string;
is_favorite?: boolean;
is_encrypted?: boolean;
parent_id: string;
};

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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) havent completed encryption onboarding yet. They will be added as pending and wont 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -1,4 +1,6 @@
export * from './DocIcon';
export * from './DocPage403';
export * from './ModalEncryptDoc';
export * from './ModalRemoveDoc';
export * from './ModalRemoveDocEncryption';
export * from './SimpleDocItem';

View File

@@ -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) {

View File

@@ -30,6 +30,7 @@ export const useCreateChildDocTree = (parentId?: string) => {
createChildDoc({
parentId,
isEncrypted: false,
});
};
};

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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();

View File

@@ -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],
});
},
});
}

View File

@@ -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,
}),
}),
});

View File

@@ -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);

View File

@@ -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"

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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"

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
}

View File

@@ -221,6 +221,7 @@ export const DocTreeItemActions = ({
createChildDoc({
parentId: doc.id,
isEncrypted: false,
});
}}
$theme="brand"

View File

@@ -92,6 +92,7 @@ export const ModalConfirmationVersion = ({
updateDoc({
id: docId,
content: version.content,
contentEncrypted: false,
});
onClose();

View File

@@ -74,6 +74,7 @@ export function useImportDoc(props?: UseImportDocOptions) {
is_creator_me: isCreatorMe,
title: undefined,
is_favorite: undefined,
is_encrypted: undefined,
},
],
},

View File

@@ -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) {

View File

@@ -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