mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +02:00
wip
This commit is contained in:
@@ -73,7 +73,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
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)
|
||||
accesses_versions_per_user = serializers.SerializerMethodField(read_only=True)
|
||||
encrypted_document_symmetric_key_for_user = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
@@ -86,7 +86,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_fingerprints_per_user",
|
||||
"accesses_versions_per_user",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
@@ -178,14 +178,14 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
def get_accesses_versions_per_user(self, instance):
|
||||
"""Return versions 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
|
||||
return instance.accesses_versions_per_user
|
||||
|
||||
def get_encrypted_document_symmetric_key_for_user(self, instance):
|
||||
"""Return the encrypted symmetric key for the current user."""
|
||||
@@ -248,7 +248,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_fingerprints_per_user",
|
||||
"accesses_versions_per_user",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
@@ -442,8 +442,8 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
required=False, allow_blank=True, write_only=True
|
||||
)
|
||||
# TODO: REQUIRED!!!
|
||||
encryption_public_key_fingerprint = serializers.CharField(
|
||||
required=False, allow_blank=True, max_length=16
|
||||
encryption_public_key_version = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
is_pending_encryption = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@@ -461,7 +461,7 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"max_ancestors_role",
|
||||
"max_role",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"encryption_public_key_fingerprint",
|
||||
"encryption_public_key_version",
|
||||
"is_pending_encryption",
|
||||
]
|
||||
read_only_fields = [
|
||||
@@ -1055,23 +1055,21 @@ class EncryptDocumentSerializer(serializers.Serializer):
|
||||
)
|
||||
# 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
|
||||
# version 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".
|
||||
# row verbatim so clients can later detect when a user's key has
|
||||
# rotated: if the current public key version differs from this
|
||||
# stored value, the access needs re-encryption.
|
||||
#
|
||||
# Not security-sensitive in the crypto sense — the actual wrap is
|
||||
# the wrapped key itself. The fingerprint is a display hint; a
|
||||
# the wrapped key itself. The version is a staleness marker; 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
|
||||
),
|
||||
encryptionPublicKeyVersionPerUser = serializers.DictField(
|
||||
child=serializers.IntegerField(allow_null=True, min_value=1),
|
||||
required=True,
|
||||
help_text=(
|
||||
"Mapping of user OIDC sub → fingerprint of their public key "
|
||||
"Mapping of user OIDC sub → version of their public key "
|
||||
"at encryption time. Must cover the same set of users as "
|
||||
"`encryptedSymmetricKeyPerUser`; null is valid for pending "
|
||||
"users."
|
||||
@@ -1104,10 +1102,10 @@ class AcceptEncryptionAccessSerializer(serializers.Serializer):
|
||||
"pending → validated. To revert, delete the access row."
|
||||
),
|
||||
)
|
||||
encryption_public_key_fingerprint = serializers.CharField(
|
||||
encryption_public_key_version = serializers.IntegerField(
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
max_length=16,
|
||||
allow_null=False,
|
||||
min_value=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2122,20 +2122,20 @@ class DocumentViewSet(
|
||||
'provide a wrapped key for your own user.'
|
||||
})
|
||||
|
||||
# Per-user fingerprint map — required, keyed on the same user
|
||||
# Per-user version 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'
|
||||
# row so clients can later detect when a user's key has rotated
|
||||
# (current version != stored version ⇒ needs re-encryption).
|
||||
version_per_user = serializer.validated_data[
|
||||
'encryptionPublicKeyVersionPerUser'
|
||||
]
|
||||
fingerprint_subs = set(fingerprint_per_user.keys())
|
||||
if fingerprint_subs != provided_user_ids:
|
||||
version_subs = set(version_per_user.keys())
|
||||
if version_subs != provided_user_ids:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptionPublicKeyFingerprintPerUser':
|
||||
'encryptionPublicKeyVersionPerUser':
|
||||
'Must cover the same set of users as encryptedSymmetricKeyPerUser. '
|
||||
f'Missing: {provided_user_ids - fingerprint_subs}. '
|
||||
f'Extra: {fingerprint_subs - provided_user_ids}.'
|
||||
f'Missing: {provided_user_ids - version_subs}. '
|
||||
f'Extra: {version_subs - provided_user_ids}.'
|
||||
})
|
||||
|
||||
# Remove old unencrypted attachment keys from the allowed list.
|
||||
@@ -2166,7 +2166,7 @@ class DocumentViewSet(
|
||||
|
||||
transaction.on_commit(_cleanup_old_attachments)
|
||||
|
||||
# Store the encrypted symmetric keys + fingerprints in
|
||||
# Store the encrypted symmetric keys + versions 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():
|
||||
@@ -2175,8 +2175,8 @@ class DocumentViewSet(
|
||||
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.encryption_public_key_version = (
|
||||
version_per_user.get(sub)
|
||||
)
|
||||
access.save()
|
||||
except models.DocumentAccess.DoesNotExist:
|
||||
@@ -2579,13 +2579,13 @@ class DocumentAccessViewSet(
|
||||
"encrypted_document_symmetric_key_for_user"
|
||||
]
|
||||
)
|
||||
access.encryption_public_key_fingerprint = (
|
||||
serializer.validated_data["encryption_public_key_fingerprint"]
|
||||
access.encryption_public_key_version = (
|
||||
serializer.validated_data["encryption_public_key_version"]
|
||||
)
|
||||
access.save(
|
||||
update_fields=[
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"encryption_public_key_fingerprint",
|
||||
"encryption_public_key_version",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Replace encryption_public_key_fingerprint with encryption_public_key_version.
|
||||
|
||||
The per-access share-time key marker moves from a fingerprint (hash of the
|
||||
public key) to the encryption key's monotonic `version` integer returned by
|
||||
the centralized encryption service. Comparing versions (current != stored) is
|
||||
cheaper and canonical for detecting when an access needs re-encryption.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0031_remove_user_encryption_public_key"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="documentaccess",
|
||||
name="encryption_public_key_fingerprint",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="documentaccess",
|
||||
name="encryption_public_key_version",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Version of the user's encryption public key at the time of sharing. "
|
||||
"Used to detect key changes — if the user's current public key version "
|
||||
"differs from this value, the access needs re-encryption."
|
||||
),
|
||||
null=True,
|
||||
verbose_name="encryption public key version",
|
||||
),
|
||||
),
|
||||
]
|
||||
+13
-14
@@ -285,15 +285,14 @@ class BaseAccess(BaseModel):
|
||||
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,
|
||||
encryption_public_key_version = models.PositiveIntegerField(
|
||||
_("encryption public key version"),
|
||||
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."
|
||||
"Version of the user's encryption public key at the time of sharing. "
|
||||
"Used to detect key changes — if the user's current public key version "
|
||||
"differs from this value, the access needs re-encryption."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -751,22 +750,22 @@ class Document(MP_Node, BaseModel):
|
||||
)
|
||||
|
||||
@property
|
||||
def accesses_fingerprints_per_user(self):
|
||||
def accesses_versions_per_user(self):
|
||||
"""
|
||||
Return the fingerprint of each user's public key at the time of sharing.
|
||||
Return the version 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.
|
||||
version stored at share time with the current public key version.
|
||||
"""
|
||||
accesses = (
|
||||
DocumentAccess.objects
|
||||
.filter(document=self, user__isnull=False, encryption_public_key_fingerprint__isnull=False)
|
||||
.values_list('user__sub', 'encryption_public_key_fingerprint')
|
||||
.filter(document=self, user__isnull=False, encryption_public_key_version__isnull=False)
|
||||
.values_list('user__sub', 'encryption_public_key_version')
|
||||
)
|
||||
|
||||
return {
|
||||
str(sub): fingerprint
|
||||
for sub, fingerprint in accesses
|
||||
if fingerprint
|
||||
str(sub): version
|
||||
for sub, version in accesses
|
||||
if version is not None
|
||||
}
|
||||
|
||||
def get_abilities(self, user):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
next-env.d.ts
|
||||
service-worker.js
|
||||
public/assets/fonts/*
|
||||
src/features/docs/doc-collaboration/vault/client-sdk.d.ts
|
||||
|
||||
@@ -2,6 +2,11 @@ import { defineConfig } from '@eslint/config-helpers';
|
||||
import docsPlugin from 'eslint-plugin-docs';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
{
|
||||
// Verbatim vendored copy of the encryption service's generated SDK
|
||||
// declaration — never hand-edited, so never linted.
|
||||
ignores: ['src/features/docs/doc-collaboration/vault/client-sdk.d.ts'],
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
docs: docsPlugin,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BoxButton } from '@/components';
|
||||
import ProConnectImg from '../assets/button-proconnect.svg';
|
||||
import { useAuth } from '../hooks';
|
||||
import { gotoLogin } from '../utils';
|
||||
|
||||
import { AccountMenu } from './AccountMenu';
|
||||
|
||||
export const ButtonLogin = () => {
|
||||
|
||||
+9
-2
@@ -30,7 +30,12 @@ export const ModalEncryptionOnboarding = ({
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !vaultClient || !containerEl || onboardingOpenedRef.current) {
|
||||
if (
|
||||
!isOpen ||
|
||||
!vaultClient ||
|
||||
!containerEl ||
|
||||
onboardingOpenedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,7 +44,9 @@ export const ModalEncryptionOnboarding = ({
|
||||
}, [isOpen, vaultClient, containerEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return;
|
||||
if (!vaultClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
// The encryption service registered the public key on its central server.
|
||||
|
||||
@@ -20,7 +20,6 @@ interface ModalEncryptionSettingsProps {
|
||||
export const ModalEncryptionSettings = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onRequestReOnboard,
|
||||
}: ModalEncryptionSettingsProps) => {
|
||||
const { client: vaultClient, refreshKeyState } = useVaultClient();
|
||||
const { refreshEncryption } = useUserEncryption();
|
||||
@@ -39,16 +38,18 @@ export const ModalEncryptionSettings = ({
|
||||
|
||||
// Listen for interface close and key changes
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return;
|
||||
if (!vaultClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
setSettingsOpened(false);
|
||||
refreshKeyState().then(() => refreshEncryption());
|
||||
void refreshKeyState().then(() => refreshEncryption());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeysDestroyed = () => {
|
||||
refreshKeyState().then(() => refreshEncryption());
|
||||
void refreshKeyState().then(() => refreshEncryption());
|
||||
};
|
||||
|
||||
vaultClient.on('interface:closed', handleClosed);
|
||||
|
||||
+10
-11
@@ -7,10 +7,10 @@
|
||||
* 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 keyVersion!: number;
|
||||
protected readonly onSystemMessage?: (message: string) => void;
|
||||
protected readonly onDecryptError?: (err: unknown) => void;
|
||||
|
||||
@@ -26,12 +26,11 @@ export class EncryptedWebSocket extends WebSocket {
|
||||
): void {
|
||||
if (type === 'message') {
|
||||
const wrappedListener: typeof listener = async (event) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messageEvent = event as any;
|
||||
const messageEvent = event as MessageEvent;
|
||||
|
||||
// System messages (strings) bypass encryption
|
||||
if (typeof messageEvent.data === 'string') {
|
||||
this.onSystemMessage?.(messageEvent.data as string);
|
||||
this.onSystemMessage?.(messageEvent.data);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -46,8 +45,9 @@ export class EncryptedWebSocket extends WebSocket {
|
||||
// Decrypt directly with ArrayBuffer — no base64 conversion
|
||||
const { data: decryptedBuffer } =
|
||||
await this.vaultClient.decryptWithKey(
|
||||
messageEvent.data as ArrayBuffer,
|
||||
messageEvent.data,
|
||||
this.encryptedSymmetricKey,
|
||||
this.keyVersion,
|
||||
);
|
||||
|
||||
const decryptedData = new Uint8Array(decryptedBuffer);
|
||||
@@ -83,8 +83,8 @@ export class EncryptedWebSocket extends WebSocket {
|
||||
get() {
|
||||
return explicitlySetListener;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
|
||||
set(handler: ((handlerEvent: MessageEvent) => any) | null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors lib.dom WebSocket.onmessage signature (=> any)
|
||||
set(_handler: ((handlerEvent: MessageEvent) => any) | null) {
|
||||
explicitlySetListener = null;
|
||||
|
||||
throw new Error(
|
||||
@@ -101,10 +101,7 @@ export class EncryptedWebSocket extends WebSocket {
|
||||
send(message: Uint8Array<ArrayBuffer>) {
|
||||
// Encrypt directly with ArrayBuffer — no base64 conversion
|
||||
this.vaultClient
|
||||
.encryptWithKey(
|
||||
message.buffer as ArrayBuffer,
|
||||
this.encryptedSymmetricKey,
|
||||
)
|
||||
.encryptWithKey(message.buffer, this.encryptedSymmetricKey)
|
||||
.then(({ encryptedData }) => {
|
||||
super.send(new Uint8Array(encryptedData));
|
||||
})
|
||||
@@ -117,12 +114,14 @@ export class EncryptedWebSocket extends WebSocket {
|
||||
export function createAdaptedEncryptedWebsocketClass(options: {
|
||||
vaultClient: VaultClient;
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
keyVersion: number;
|
||||
onSystemMessage?: (message: string) => void;
|
||||
onDecryptError?: (err: unknown) => void;
|
||||
}) {
|
||||
return class extends EncryptedWebSocket {
|
||||
protected readonly vaultClient = options.vaultClient;
|
||||
protected readonly encryptedSymmetricKey = options.encryptedSymmetricKey;
|
||||
protected readonly keyVersion = options.keyVersion;
|
||||
protected readonly onSystemMessage = options.onSystemMessage;
|
||||
protected readonly onDecryptError = options.onDecryptError;
|
||||
};
|
||||
|
||||
@@ -40,8 +40,15 @@ export async function exportPublicKeyAsBase64(
|
||||
|
||||
// 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;
|
||||
const {
|
||||
d: _d,
|
||||
p: _p,
|
||||
q: _q,
|
||||
dp: _dp,
|
||||
dq: _dq,
|
||||
qi: _qi,
|
||||
...publicJwk
|
||||
} = privateJwk;
|
||||
|
||||
return { ...publicJwk, key_ops: ['encrypt'] };
|
||||
}
|
||||
|
||||
+21
-5
@@ -21,6 +21,14 @@ export interface DocumentEncryptionSettings {
|
||||
* Pass this to VaultClient.encryptWithKey() / decryptWithKey().
|
||||
*/
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
/**
|
||||
* The current user's encryption-key VERSION this wrapped symmetric key was
|
||||
* produced against (the share-time version stored per access). Passed as the
|
||||
* `keyVersion` argument to VaultClient.decryptWithKey() so the vault selects
|
||||
* the matching private key. Same source KeyMismatchPanel reads:
|
||||
* `doc.accesses_versions_per_user[user.suite_user_id]`.
|
||||
*/
|
||||
keyVersion: number;
|
||||
}
|
||||
|
||||
/** Convert a base64 string to ArrayBuffer */
|
||||
@@ -32,12 +40,13 @@ function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer as ArrayBuffer;
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
export function useDocumentEncryption(
|
||||
isDocumentEncrypted: boolean | undefined,
|
||||
userEncryptedSymmetricKeyBase64: string | undefined,
|
||||
keyVersion: number | undefined,
|
||||
): {
|
||||
documentEncryptionLoading: boolean;
|
||||
documentEncryptionSettings: DocumentEncryptionSettings | null;
|
||||
@@ -49,7 +58,9 @@ export function useDocumentEncryption(
|
||||
|
||||
// Convert the base64 key from the API to ArrayBuffer (memoized)
|
||||
const encryptedSymmetricKey = useMemo(() => {
|
||||
if (!userEncryptedSymmetricKeyBase64) return null;
|
||||
if (!userEncryptedSymmetricKeyBase64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return base64ToArrayBuffer(userEncryptedSymmetricKeyBase64);
|
||||
@@ -59,10 +70,15 @@ export function useDocumentEncryption(
|
||||
}, [userEncryptedSymmetricKeyBase64]);
|
||||
|
||||
const settings = useMemo<DocumentEncryptionSettings | null>(() => {
|
||||
if (!encryptedSymmetricKey) return null;
|
||||
if (!encryptedSymmetricKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { encryptedSymmetricKey };
|
||||
}, [encryptedSymmetricKey]);
|
||||
return {
|
||||
encryptedSymmetricKey,
|
||||
keyVersion: keyVersion ?? 1,
|
||||
};
|
||||
}, [encryptedSymmetricKey, keyVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!encryptionLoading && !encryptionSettings) {
|
||||
|
||||
+3
-3
@@ -27,7 +27,7 @@ export function useEncryption(
|
||||
} | 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
|
||||
const enableEncryption = true; // TODO: this could be toggled for instances not needing encryption to save some requests
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -39,7 +39,7 @@ export function useEncryption(
|
||||
setSettings(null);
|
||||
setError(null);
|
||||
return;
|
||||
} else if (enableEncryption === false) {
|
||||
} else if (!enableEncryption) {
|
||||
setLoading(false);
|
||||
setSettings(null);
|
||||
setError(null);
|
||||
@@ -99,7 +99,7 @@ export function useEncryption(
|
||||
}
|
||||
}
|
||||
|
||||
initEncryption();
|
||||
void initEncryption();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export function useKeyFingerprint(
|
||||
|
||||
let cancelled = false;
|
||||
const raw = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0));
|
||||
vaultClient.computeKeyFingerprint(raw.buffer).then((fp) => {
|
||||
void vaultClient.computeKeyFingerprint(raw.buffer).then((fp) => {
|
||||
if (!cancelled) {
|
||||
setFingerprint(vaultClient.formatFingerprint(fp));
|
||||
}
|
||||
|
||||
+3
-4
@@ -53,15 +53,14 @@ export function usePublicKeyRegistry(
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const accesses = accessesPublicKeysPerUser;
|
||||
|
||||
async function checkKeys() {
|
||||
try {
|
||||
const db = await getEncryptionDB();
|
||||
const newMismatches: PublicKeyMismatch[] = [];
|
||||
|
||||
for (const [userId, currentKey] of Object.entries(
|
||||
accessesPublicKeysPerUser!,
|
||||
)) {
|
||||
for (const [userId, currentKey] of Object.entries(accesses)) {
|
||||
// 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
|
||||
@@ -95,7 +94,7 @@ export function usePublicKeyRegistry(
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
checkKeys();
|
||||
void checkKeys();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
+18
-8
@@ -19,15 +19,13 @@ import {
|
||||
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 VAULT_URL = process.env.NEXT_PUBLIC_VAULT_URL ?? 'http://localhost:7201';
|
||||
const INTERFACE_URL =
|
||||
process.env.NEXT_PUBLIC_INTERFACE_URL ?? 'http://localhost:7202';
|
||||
|
||||
@@ -111,7 +109,9 @@ export function VaultClientProvider({
|
||||
|
||||
// Load script + initialize VaultClient once
|
||||
useEffect(() => {
|
||||
if (initRef.current) return;
|
||||
if (initRef.current) {
|
||||
return;
|
||||
}
|
||||
initRef.current = true;
|
||||
|
||||
let destroyed = false;
|
||||
@@ -120,7 +120,9 @@ export function VaultClientProvider({
|
||||
try {
|
||||
await loadClientScript();
|
||||
|
||||
if (destroyed) return;
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new window.EncryptionClient.VaultClient({
|
||||
vaultUrl: VAULT_URL,
|
||||
@@ -185,6 +187,9 @@ export function VaultClientProvider({
|
||||
clientRef.current = null;
|
||||
}
|
||||
};
|
||||
// One-time init: theme and language are read from the first render only,
|
||||
// re-initializing the client on those changes is intentionally avoided.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Set auth context whenever user changes or client finishes initializing
|
||||
@@ -202,12 +207,15 @@ export function VaultClientProvider({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const suiteUserId = user.suite_user_id;
|
||||
|
||||
async function setupAuth() {
|
||||
if (cancelled || !client) return;
|
||||
if (cancelled || !client) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.setAuthContext({
|
||||
suiteUserId: user!.suite_user_id!,
|
||||
suiteUserId,
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
@@ -239,7 +247,9 @@ export function VaultClientProvider({
|
||||
const refreshKeyState = useCallback(async () => {
|
||||
const client = clientRef.current;
|
||||
|
||||
if (!client) return;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { hasKeys: exists } = await client.hasKeys();
|
||||
|
||||
+582
@@ -0,0 +1,582 @@
|
||||
export declare interface AuthContext {
|
||||
/**
|
||||
* The identity provider's `sub` claim for the logged-in user. Products only
|
||||
* ever deal in subs — their own APIs know users by sub, and every SDK
|
||||
* operation (recipients, fingerprints, profiles) takes subs. The vault
|
||||
* resolves them to its internal, migration-stable user ids at its boundary
|
||||
* (local alias map, then public registry); that internal id never has to be
|
||||
* handled, stored, or even seen by a product.
|
||||
*/
|
||||
suiteUserId: string;
|
||||
}
|
||||
|
||||
export declare interface EncryptionClientEventMap {
|
||||
/** Fired when the hidden vault iframe is ready for encrypt/decrypt operations */
|
||||
[MSG_VAULT_READY]: void;
|
||||
/** Fired when the user completes onboarding (key generation + backup) */
|
||||
'onboarding:complete': {
|
||||
publicKey: string;
|
||||
};
|
||||
/** Fired when the user cancels or closes the interface */
|
||||
[MSG_INTERFACE_CLOSED]: void;
|
||||
/** Fired on errors from the vault or the interface */
|
||||
error: Error;
|
||||
/** Fired when keys changed from another tab/product (via BroadcastChannel) */
|
||||
'keys-changed': void;
|
||||
/** Fired when keys were destroyed from another tab/product (via BroadcastChannel) */
|
||||
'keys-destroyed': void;
|
||||
/** Fired when a fingerprint is accepted or refused in the local registry */
|
||||
'fingerprint-changed': void;
|
||||
}
|
||||
|
||||
export declare interface EncryptionClientOptions {
|
||||
/** URL of the vault domain (data.encryption), e.g. "https://data.encryption.numerique.gouv.fr" */
|
||||
vaultUrl: string;
|
||||
/** URL of the interface domain (encryption), e.g. "https://encryption.numerique.gouv.fr" */
|
||||
interfaceUrl: string;
|
||||
/** Timeout in ms for vault operations (default: 30000) */
|
||||
timeout?: number;
|
||||
/**
|
||||
* Cunningham theme name for the interface iframe.
|
||||
* Standard names: "default", "dark", "dsfr", "dsfr-dark", "anct", "anct-dark".
|
||||
* Default: "default"
|
||||
*/
|
||||
theme?: string;
|
||||
/** Language code for the interface iframe: "fr", "en", etc. (default: browser language) */
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export declare const isVaultError: (err: unknown) => err is VaultError;
|
||||
|
||||
declare type Listener<K extends keyof EncryptionClientEventMap> = (data: EncryptionClientEventMap[K]) => void;
|
||||
|
||||
declare const MSG_INTERFACE_CLOSED = "interface:closed";
|
||||
|
||||
declare const MSG_VAULT_READY = "vault:ready";
|
||||
|
||||
export declare type RecipientLabel = {
|
||||
email: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Union on `verified` on purpose: `encryptionPublicKey` is a usable
|
||||
* `ArrayBuffer` only in the verified branch (`null` otherwise), so a caller
|
||||
* cannot wrap a key for a forged / incoherent directory entry without the
|
||||
* compiler stopping them. Identity and encryption key live in one per-user
|
||||
* entry so the two can never disagree for a user.
|
||||
*/
|
||||
export declare type RegisteredUser = {
|
||||
verified: true;
|
||||
signaturePublicKey: ArrayBuffer;
|
||||
identityFingerprint: string;
|
||||
version: number;
|
||||
createdAtMillis: number;
|
||||
encryptionPublicKey: ArrayBuffer;
|
||||
} | {
|
||||
verified: false;
|
||||
signaturePublicKey: ArrayBuffer;
|
||||
identityFingerprint: string;
|
||||
version: number;
|
||||
createdAtMillis: number;
|
||||
encryptionPublicKey: null;
|
||||
};
|
||||
|
||||
export declare class VaultClient {
|
||||
private vaultIframe;
|
||||
private interfaceIframe;
|
||||
private pending;
|
||||
private listeners;
|
||||
private vaultReadyResolve;
|
||||
private initTimeoutId;
|
||||
private messageHandler;
|
||||
private vaultUrl;
|
||||
private interfaceUrl;
|
||||
private vaultOrigin;
|
||||
private interfaceOrigin;
|
||||
private timeout;
|
||||
private theme;
|
||||
private lang;
|
||||
private authContext;
|
||||
private verifyOverlay;
|
||||
private verifyResolve;
|
||||
private emergencyOverlay;
|
||||
private emergencySurfaced;
|
||||
private emergencyWatchdog;
|
||||
private pendingContext;
|
||||
constructor(options: EncryptionClientOptions);
|
||||
/**
|
||||
* Update the Cunningham theme. If the interface iframe is open, sends it a
|
||||
* theme message so it re-themes in place.
|
||||
* @param theme - Cunningham theme name: "default", "dark", "dsfr", "dsfr-dark", "anct", "anct-dark", etc.
|
||||
*/
|
||||
setTheme(theme: string): void;
|
||||
/**
|
||||
* Set the authentication context. Must be called before opening the interface.
|
||||
* The suiteUserId is passed to the interface iframe so it can register
|
||||
* public keys on the server and perform device transfers.
|
||||
*/
|
||||
setAuthContext(context: AuthContext): void;
|
||||
/**
|
||||
* Read back the auth context that was set via {@link setAuthContext}.
|
||||
* Returns `null` until `setAuthContext` has been called. Useful when
|
||||
* the host app needs to identify the currently-bound suite user from
|
||||
* code that doesn't otherwise have access to the auth state — e.g.
|
||||
* the Drive driver's encryption-aware move handler, which has to
|
||||
* verify the operator (mover) keeps a per-user wrap during a
|
||||
* re-anchor and would otherwise need to thread the user identifier
|
||||
* through every call site.
|
||||
*/
|
||||
getAuthContext(): AuthContext | null;
|
||||
/**
|
||||
* Initialize the encryption client.
|
||||
* Creates a hidden vault iframe (data.encryption) and waits for it to be ready.
|
||||
*/
|
||||
init(): Promise<void>;
|
||||
/**
|
||||
* Clean up all iframes and event listeners.
|
||||
*/
|
||||
destroy(): void;
|
||||
/** Check if the user has encryption keys on this device. */
|
||||
hasKeys(): Promise<{
|
||||
hasKeys: boolean;
|
||||
}>;
|
||||
/** Get the user's public key as ArrayBuffer. */
|
||||
getPublicKey(): Promise<{
|
||||
publicKey: ArrayBuffer;
|
||||
}>;
|
||||
/**
|
||||
* Create a new ROOT encrypted resource. Mints a fresh symmetric key,
|
||||
* encrypts `data` with it, and wraps the new key once per recipient.
|
||||
*
|
||||
* Pass recipients as a labeled map (OIDC sub → {email, name?}), not public
|
||||
* keys — the subs your product already holds for its users. The vault
|
||||
* resolves + trust-checks each recipient (binding + TOFU 'trusted' with
|
||||
* matching fingerprint, keyed internally so trust survives an OIDC provider
|
||||
* migration) before wrapping, and throws UNTRUSTED_RECIPIENT if any is
|
||||
* unverified or untrusted. Call checkFingerprints first to resolve any
|
||||
* 'unknown' contacts. The labels are display-only and used only if the trust
|
||||
* modal opens; the vault request itself sees just the subs.
|
||||
*
|
||||
* Use this for standalone encrypted files (no parent) and for the root
|
||||
* folder of an encrypted subtree.
|
||||
*
|
||||
* @returns encryptedContent and encryptedKeys (userId → wrappedKey)
|
||||
*/
|
||||
encryptWithoutKey(data: ArrayBuffer, recipients: Record<string, RecipientLabel>, options?: {
|
||||
optimizeMemory?: boolean;
|
||||
}): Promise<{
|
||||
encryptedContent: ArrayBuffer;
|
||||
encryptedKeys: Record<string, ArrayBuffer>;
|
||||
}>;
|
||||
private encryptWithoutKeyRequest;
|
||||
/**
|
||||
* Create a new NESTED encrypted resource inside an existing encrypted
|
||||
* subtree. Resolves `entry + chain` to the parent folder's key, mints a
|
||||
* fresh symmetric key, encrypts `data` with it, and wraps the new key
|
||||
* under the parent's key.
|
||||
*
|
||||
* Use this for creating a file inside an already-encrypted folder.
|
||||
* Persist the returned `wrappedKey` on the new item's DB row.
|
||||
*
|
||||
* @param data - ArrayBuffer content to encrypt
|
||||
* @param encryptedSymmetricKey - caller's entry key (asymmetric bootstrap
|
||||
* into the subtree, same value used for decryptWithKey)
|
||||
* @param encryptedKeyChain - optional symmetric wrappings from entry down
|
||||
* to the parent folder (exclusive of the new resource, since it doesn't
|
||||
* exist yet). Empty/omitted means the entry key IS the parent's key.
|
||||
*/
|
||||
encryptNestedWithoutKey(data: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer, encryptedKeyChain?: ArrayBuffer[], options?: {
|
||||
optimizeMemory?: boolean;
|
||||
}): Promise<{
|
||||
encryptedContent: ArrayBuffer;
|
||||
wrappedKey: ArrayBuffer;
|
||||
}>;
|
||||
/**
|
||||
* Encrypt content with an EXISTING symmetric key — pure symmetric mirror
|
||||
* of `decryptWithKey`. No new key is minted.
|
||||
*
|
||||
* Without `encryptedKeyChain`: resolves `encryptedSymmetricKey` (user's
|
||||
* entry key) and encrypts with it. Used by the flat model (Docs).
|
||||
*
|
||||
* With `encryptedKeyChain`: resolves entry + chain to the terminal
|
||||
* symmetric key and encrypts with it. Used by the collaborative relay
|
||||
* to encrypt messages tied to an existing file inside an encrypted
|
||||
* hierarchy, so that sender and receiver converge on the same K_file.
|
||||
*/
|
||||
encryptWithKey(data: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer, encryptedKeyChain?: ArrayBuffer[], options?: {
|
||||
optimizeMemory?: boolean;
|
||||
}): Promise<{
|
||||
encryptedData: ArrayBuffer;
|
||||
}>;
|
||||
/**
|
||||
* Decrypt content using a separately provided encrypted symmetric key.
|
||||
* The symmetric key decryption is cached per session for performance.
|
||||
*
|
||||
* @param encryptedData - ArrayBuffer ciphertext to decrypt
|
||||
* @param encryptedSymmetricKey - user's encrypted copy of the symmetric key
|
||||
* @param keyVersion - the recipient's encryption-key VERSION this wrap was
|
||||
* produced against, as stored by the product on the access row. The vault
|
||||
* unwraps with exactly that retained key (a version this device no longer
|
||||
* holds throws WRONG_SECRET_KEY). For Drive chains it is the version of the
|
||||
* ENTRY-point key; the chain links themselves are symmetric.
|
||||
* @param encryptedKeyChain - optional chain of wrapped keys for Drive's key hierarchy.
|
||||
* When provided, resolves the chain from entry point to target before decrypting.
|
||||
*/
|
||||
decryptWithKey(encryptedData: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer, keyVersion: number, encryptedKeyChain?: ArrayBuffer[], options?: {
|
||||
optimizeMemory?: boolean;
|
||||
}): Promise<{
|
||||
data: ArrayBuffer;
|
||||
}>;
|
||||
/**
|
||||
* Re-wrap a nested resource's symmetric key from one parent chain onto
|
||||
* another. Used when MOVING an encrypted file/folder between positions
|
||||
* inside the same encrypted subtree: the file's content is left
|
||||
* untouched (still encrypted with K_file), but K_file's wrapping
|
||||
* follows its new parent.
|
||||
*
|
||||
* @param encryptedSymmetricKey - the user's entry-point key (root key
|
||||
* wrapped under the user's pubkey). Same value for both old and new
|
||||
* chains since this operation stays within a single encrypted root.
|
||||
* @param oldEncryptedKey - the resource's K_file as currently stored,
|
||||
* wrapped under its OLD parent's key.
|
||||
* @param oldEncryptedKeyChain - chain of wrapped folder keys from the
|
||||
* entry point down to (and including) the OLD parent's key. Omit /
|
||||
* pass `undefined` when the OLD parent IS the encryption root.
|
||||
* @param newEncryptedKeyChain - chain entry → NEW parent. Omit when
|
||||
* the NEW parent is the encryption root.
|
||||
* @returns the resource's K_file re-wrapped under the NEW parent's
|
||||
* key — caller persists this on the resource's DB row, replacing
|
||||
* the old wrapping.
|
||||
*/
|
||||
rewrapNestedKey(encryptedSymmetricKey: ArrayBuffer, oldEncryptedKey: ArrayBuffer, oldEncryptedKeyChain?: ArrayBuffer[], newEncryptedKeyChain?: ArrayBuffer[]): Promise<{
|
||||
newEncryptedKey: ArrayBuffer;
|
||||
}>;
|
||||
/**
|
||||
* Wrap an existing per-user-anchored symmetric key under a parent
|
||||
* chain. Symmetric reverse of {@link shareKeys} (which goes 1→N
|
||||
* chain→per-user); this goes 1→1 per-user→chain.
|
||||
*
|
||||
* Used when MOVING a self-rooted encrypted resource (per-user
|
||||
* wraps on its access rows) INTO an encrypted subtree: K_item is
|
||||
* recovered from the user's per-user wrap, then wrapped under the
|
||||
* destination parent's chain so the resource stops being a root
|
||||
* and joins the destination tree.
|
||||
*
|
||||
* @param userEncryptedKey - the resource's per-user wrap from the
|
||||
* caller's access row (`encrypted_item_symmetric_key_for_user`).
|
||||
* @param newEntryEncryptedSymmetricKey - user's entry-point key
|
||||
* for the destination tree (the tree's root key wrapped under
|
||||
* their pubkey — same value `getKeyChain` returns as
|
||||
* `encrypted_key_for_user` for any item under that tree).
|
||||
* @param newEncryptedKeyChain - chain entry → NEW parent. Omit
|
||||
* when the new parent IS the destination tree's root.
|
||||
* @returns the resource's K_item wrapped under the new parent's
|
||||
* key — caller persists this on the resource's row.
|
||||
*/
|
||||
wrapNestedKey(userEncryptedKey: ArrayBuffer, newEntryEncryptedSymmetricKey: ArrayBuffer, newEncryptedKeyChain?: ArrayBuffer[]): Promise<{
|
||||
newEncryptedKey: ArrayBuffer;
|
||||
}>;
|
||||
/**
|
||||
* Share an existing document's or item's symmetric key with additional users.
|
||||
*
|
||||
* Pass recipients as a labeled map (OIDC sub → {email, name?}), not public
|
||||
* keys: the vault resolves each recipient's encryption key from the directory
|
||||
* itself and wraps ONLY for identities whose binding verifies AND that you have marked
|
||||
* 'trusted' (TOFU) with a matching fingerprint. If any recipient is unverified
|
||||
* or untrusted it throws UNTRUSTED_RECIPIENT and wraps for none — call
|
||||
* checkFingerprints (and resolve any 'unknown') first. Recipients are resolved
|
||||
* in one batched request. The labels are display-only and used only if the
|
||||
* trust modal opens; the vault request itself sees just the userIds.
|
||||
*
|
||||
* @param encryptedSymmetricKey - current user's encrypted copy of the key
|
||||
* @param recipients - map of userId → display label to share with
|
||||
* @param encryptedKeyChain - optional chain of wrapped keys for Drive's key hierarchy.
|
||||
* When provided, resolves the chain from entry point to the target item's key
|
||||
* before re-encrypting for the target users.
|
||||
* @returns encryptedKeys - Record of userId → ArrayBuffer encrypted symmetric key for each user
|
||||
*/
|
||||
shareKeys(encryptedSymmetricKey: ArrayBuffer, recipients: Record<string, RecipientLabel>, encryptedKeyChain?: ArrayBuffer[]): Promise<{
|
||||
encryptedKeys: Record<string, ArrayBuffer>;
|
||||
}>;
|
||||
private shareKeysRequest;
|
||||
/**
|
||||
* Fetch registered users for a list of OIDC subs (the ids your product
|
||||
* already holds). The vault calls the encryption server itself (products
|
||||
* never touch it) and verifies each record's binding signature before
|
||||
* returning. The map is keyed by the subs you queried; subs with no active
|
||||
* registration are absent (that person never onboarded encryption). Use it
|
||||
* when building a sharing UI: it tells you who has keys, their fingerprint,
|
||||
* and whether the directory record is coherent (`verified`).
|
||||
*/
|
||||
fetchPublicKeys(subs: string[]): Promise<Record<string, RegisteredUser>>;
|
||||
/**
|
||||
* Check fingerprints provided by the product against the vault's local registry.
|
||||
* The product sends the fingerprints it stored at share time, keyed by OIDC
|
||||
* sub; results echo the same subs back (the vault translates to its internal
|
||||
* trust keys on its side).
|
||||
*
|
||||
* Returns results with status: "trusted", "refused", or "unknown" (needs user decision).
|
||||
*/
|
||||
checkFingerprints(userFingerprints: Record<string, string>): Promise<{
|
||||
results: Array<{
|
||||
userId: string;
|
||||
knownFingerprint: string | null;
|
||||
providedFingerprint: string;
|
||||
status: 'trusted' | 'refused' | 'unknown' | 'mismatch';
|
||||
}>;
|
||||
}>;
|
||||
/**
|
||||
* Get all known fingerprints with their status from the local registry.
|
||||
*/
|
||||
getKnownFingerprints(): Promise<{
|
||||
fingerprints: Record<string, {
|
||||
fingerprint: string;
|
||||
status: 'trusted' | 'refused' | 'unknown';
|
||||
}>;
|
||||
}>;
|
||||
/**
|
||||
* Compute a 128-bit DECIMAL fingerprint of a public key: the first 16 bytes of
|
||||
* its SHA-256, read big-endian as a fixed-width 40-digit decimal. Matches the
|
||||
* device-pairing fingerprint so every surface shows the same value.
|
||||
* This is a pure client-side operation — no vault iframe needed.
|
||||
*
|
||||
* @param publicKey - The public key as ArrayBuffer (from fetchPublicKeys or getPublicKey)
|
||||
*/
|
||||
computeKeyFingerprint(publicKey: ArrayBuffer): Promise<string>;
|
||||
/**
|
||||
* Format a raw decimal fingerprint for display, grouped in blocks of five.
|
||||
*/
|
||||
formatFingerprint(fingerprint: string): string;
|
||||
/**
|
||||
* Open the encryption interface for onboarding (key generation + backup).
|
||||
* The product provides a container element where the interface iframe will be mounted.
|
||||
* The product is responsible for showing/hiding this container (e.g. in a modal).
|
||||
*
|
||||
* Listen to 'onboarding:complete' and 'interface:closed' events for results.
|
||||
*/
|
||||
openOnboarding(container: HTMLElement): void;
|
||||
/**
|
||||
* Open the encryption interface for key backup/export.
|
||||
*/
|
||||
openBackup(container: HTMLElement): void;
|
||||
/**
|
||||
* Open the encryption interface for key restoration from backup.
|
||||
*/
|
||||
openRestore(container: HTMLElement): void;
|
||||
/**
|
||||
* Open the encryption settings (view fingerprint, delete keys).
|
||||
*/
|
||||
openSettings(container: HTMLElement): void;
|
||||
/**
|
||||
* Open device approval: enroll this device from another, or approve a new one.
|
||||
*/
|
||||
openDeviceApproval(container: HTMLElement): void;
|
||||
/**
|
||||
* Open the emergency-access (trusted contacts) management screen: designate
|
||||
* contacts, accept a designation, follow or refuse a running recovery.
|
||||
*/
|
||||
openEmergencyAccess(container: HTMLElement): void;
|
||||
/**
|
||||
* Open the per-recipient profile: the recipient's current trust decision, their
|
||||
* identity fingerprint (for out-of-band comparison), and Trust / Refuse actions.
|
||||
* Opened explicitly by the product (e.g. clicking a person in its share UI), so
|
||||
* it mounts in a product-provided container like the other open* methods.
|
||||
* `userId` is the recipient's OIDC sub, like every id a product passes.
|
||||
*/
|
||||
openRecipientProfile(container: HTMLElement, userId: string, label: RecipientLabel): void;
|
||||
/**
|
||||
* Close the interface iframe if it is open.
|
||||
*/
|
||||
closeInterface(): void;
|
||||
on<K extends keyof EncryptionClientEventMap>(event: K, listener: Listener<K>): void;
|
||||
off<K extends keyof EncryptionClientEventMap>(event: K, listener: Listener<K>): void;
|
||||
private openInterface;
|
||||
/**
|
||||
* Construct and configure an interface iframe for `path` (sandbox, allow,
|
||||
* theme/lang hash, context handshake). Mounting is left to the caller so the
|
||||
* same setup serves both the product-provided container (openInterface) and
|
||||
* the SDK-created full-screen overlay (openVerifyRecipients).
|
||||
*
|
||||
* `overlay` travels in the HASH, not in the postMessage context, precisely
|
||||
* because the context arrives asynchronously: a screen that renders as a page
|
||||
* when embedded and as a modal when overlaid would otherwise paint the page
|
||||
* variant first (full-width and opaque, over the product) and swap to the modal
|
||||
* only once the handshake lands, which reads as a flash.
|
||||
*/
|
||||
private buildInterfaceIframe;
|
||||
/** The context message currently owed to the interface, or null before auth. */
|
||||
private sendContext;
|
||||
/**
|
||||
* Open the SDK-owned "verify recipients" overlay on top of the product's own
|
||||
* share dialog, and resolve with the user's outcome. The SDK deliberately does
|
||||
* NOT draw any chrome here: the container is a minimal, transparent, full-
|
||||
* viewport layer, and the interface (a Cunningham Modal) draws the whole modal
|
||||
* (its own backdrop + card) inside the transparent iframe, so it matches the
|
||||
* rest of the interface UI. Tears the overlay down once the outcome is in.
|
||||
*/
|
||||
private openVerifyRecipients;
|
||||
/**
|
||||
* Auto-open the interface over the product when the vault reports actionable
|
||||
* emergency-access state: a running recovery request against the user's vault
|
||||
* (which they must be able to refuse without hunting for a menu) or a pending
|
||||
* trusted-contact designation to accept. Same transparent-overlay technique
|
||||
* as the verify-recipients flow; at most once per page load, and never while
|
||||
* another interface flow is already open (the settings screen shows the same
|
||||
* state anyway).
|
||||
*
|
||||
* This one is the ONLY flow the SDK opens on its own initiative, so it is held
|
||||
* to a stricter rule than the flows a product asked for: it stays invisible
|
||||
* until the interface says it is up, and if it never says so it is removed
|
||||
* rather than left covering the page. Both halves hang off the same signal:
|
||||
*
|
||||
* - the interface asks for its context (MSG_INTERFACE_REQUEST_CONTEXT) as soon
|
||||
* as its React app mounts, so that is "the remote page is ready";
|
||||
* - until then `visibility: hidden` keeps the blank document, then the app's
|
||||
* first frames, off the screen;
|
||||
* - if it never arrives (bundle blocked, offline, a redirect leaving an empty
|
||||
* document, a crash before mount) the watchdog removes the whole overlay, so
|
||||
* a broken interface never sits on top of the product swallowing clicks. The
|
||||
* user loses nothing: the same state is in the settings screen, and the
|
||||
* load-bearing channel for all of this is email.
|
||||
*/
|
||||
private surfaceEmergencyPending;
|
||||
/**
|
||||
* The interface app mounted. Reveal the overlay we kept hidden and stand the
|
||||
* watchdog down. No-op for every other flow (the product owns their container).
|
||||
*/
|
||||
private revealEmergencyOverlay;
|
||||
private teardownEmergencyOverlay;
|
||||
private completeVerify;
|
||||
private teardownVerifyOverlay;
|
||||
/**
|
||||
* Run a recipient-bearing operation, and on UNTRUSTED_RECIPIENT open the shared
|
||||
* verify modal for the ORIGINAL recipients (full labeled map; the interface
|
||||
* surfaces only the blocked ones). If the user trusts them all, retry the
|
||||
* operation exactly once; otherwise rethrow the original error so the product
|
||||
* sees the share failed (all-or-nothing). Any other error rethrows unchanged.
|
||||
* This is always on: whether to prompt for trust is not a product choice, so
|
||||
* there is no opt-out.
|
||||
*/
|
||||
private withRecipientVerification;
|
||||
private vaultRequest;
|
||||
private handleMessage;
|
||||
private handleVaultMessage;
|
||||
private handleInterfaceMessage;
|
||||
private removeIframe;
|
||||
private emit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error subclass tagged with a stable {@link VaultErrorCode}. Throw this
|
||||
* (instead of `new Error(...)`) anywhere inside the SDK so the boundary
|
||||
* marshaller can pass the code along to consumers.
|
||||
*
|
||||
* Note: `VaultError` instances DO NOT survive `structuredClone` — the
|
||||
* postMessage layer marshals `{ message, code }` explicitly and
|
||||
* reconstructs the class on the receiving side.
|
||||
*/
|
||||
export declare class VaultError extends Error {
|
||||
readonly code: VaultErrorCode;
|
||||
constructor(code: VaultErrorCode, message: string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable error codes that travel across the vault iframe ↔ client
|
||||
* postMessage boundary. Consumers (drive / docs / meet) match on these
|
||||
* instead of regexing error messages — message text is for logs / humans
|
||||
* and may change, the codes are part of the SDK contract.
|
||||
*
|
||||
* Why a string-keyed const (not a TS enum): keeps the runtime values
|
||||
* tree-shakeable, and the strings double as wire identifiers when
|
||||
* marshalled over postMessage.
|
||||
*/
|
||||
export declare const VaultErrorCode: {
|
||||
/** No key pair stored locally on this device — user must onboard. */
|
||||
readonly MISSING_KEYS: "MISSING_KEYS";
|
||||
/**
|
||||
* AEAD verification failed. Either the ciphertext is for a different
|
||||
* recipient (their wrapped symmetric key was encrypted against another
|
||||
* pubkey) or the underlying KEM secret didn't match. Bubbles up from
|
||||
* libsodium's "wrong secret key for the given ciphertext".
|
||||
*/
|
||||
readonly WRONG_SECRET_KEY: "WRONG_SECRET_KEY";
|
||||
/** Backup payload is corrupted, truncated, or from an unsupported version. */
|
||||
readonly INVALID_BACKUP: "INVALID_BACKUP";
|
||||
/** BIP-39-style mnemonic input that doesn't checksum. */
|
||||
readonly INVALID_MNEMONIC: "INVALID_MNEMONIC";
|
||||
/** Caller hit a vault method without first calling `init()`. */
|
||||
readonly NOT_INITIALIZED: "NOT_INITIALIZED";
|
||||
/** `setAuthContext({ suiteUserId })` was never called. */
|
||||
readonly AUTH_REQUIRED: "AUTH_REQUIRED";
|
||||
/**
|
||||
* The declared sub could not be resolved to an internal encryption user id:
|
||||
* no local alias, and no directory row (either the user never onboarded, or
|
||||
* the registry was unreachable with nothing cached). Thrown by every vault
|
||||
* operation EXCEPT `has-keys`, which responds `{ hasKeys: false }` instead
|
||||
* of throwing: for that probe, "unresolvable" and "never onboarded" are the
|
||||
* same answer, and products use it to decide whether to offer onboarding.
|
||||
*/
|
||||
readonly UNRESOLVED_USER: "UNRESOLVED_USER";
|
||||
/** Privileged operation attempted from a non-encryption-origin caller. */
|
||||
readonly PRIVILEGED_ORIGIN_REQUIRED: "PRIVILEGED_ORIGIN_REQUIRED";
|
||||
/** A vault request didn't get an answer within the configured timeout. */
|
||||
readonly TIMEOUT: "TIMEOUT";
|
||||
/** Vault module loaded outside an iframe (origin-isolation invariant). */
|
||||
readonly IFRAME_REQUIRED: "IFRAME_REQUIRED";
|
||||
/** Ciphertext / encrypted-key payload too short to be valid (truncated). */
|
||||
readonly CIPHERTEXT_TOO_SHORT: "CIPHERTEXT_TOO_SHORT";
|
||||
/** Blob's leading version byte doesn't match a format this build can decode. */
|
||||
readonly UNSUPPORTED_CRYPTO_VERSION: "UNSUPPORTED_CRYPTO_VERSION";
|
||||
/** A signature public key didn't have the expected Ed25519 length. */
|
||||
readonly INVALID_SIGNATURE_KEY: "INVALID_SIGNATURE_KEY";
|
||||
/**
|
||||
* A registry entry's binding signature did not verify against its claimed
|
||||
* identity (signature) key — the directory record is forged, tampered, or
|
||||
* incoherent. Consumers MUST refuse to trust / share with such an entry.
|
||||
*/
|
||||
readonly INVALID_KEY_BINDING: "INVALID_KEY_BINDING";
|
||||
/**
|
||||
* A pulled vault failed its integrity check: the identity-signed manifest did
|
||||
* not verify, an item's ciphertext hash or coverage did not match, or the
|
||||
* revision rolled back. Distinct from a wrong recovery phrase (which fails the
|
||||
* unlock, not the integrity check) — it means the SERVER served tampered or
|
||||
* incoherent vault data, so the user must be warned, not told to re-type.
|
||||
*/
|
||||
readonly VAULT_INTEGRITY_FAILED: "VAULT_INTEGRITY_FAILED";
|
||||
/**
|
||||
* A wrap was attempted for a recipient whose identity is not TOFU-'trusted'
|
||||
* with a matching fingerprint (refused, never verified, or a fingerprint
|
||||
* mismatch that may be a MITM-substituted key). The vault refuses to wrap the
|
||||
* symmetric key until the recipient's identity is verified. The offending
|
||||
* userIds are in the error message.
|
||||
*/
|
||||
readonly UNTRUSTED_RECIPIENT: "UNTRUSTED_RECIPIENT";
|
||||
/**
|
||||
* A write-through change (e.g. a TOFU decision) could not be pushed to the
|
||||
* server, so it was NOT kept locally either — the caller should surface a
|
||||
* "couldn't save, retry" and the local state is unchanged. Distinct from a
|
||||
* network throw (which also aborts before persisting).
|
||||
*/
|
||||
readonly SYNC_FAILED: "SYNC_FAILED";
|
||||
/**
|
||||
* The request reached a handler but its payload does not satisfy that
|
||||
* operation's contract (a missing required field, or none of a set of mutually
|
||||
* exclusive ones). A CALLER bug, not a user condition: it is surfaced rather
|
||||
* than defaulted so a malformed call fails loud instead of acting on a
|
||||
* half-specified target.
|
||||
*/
|
||||
readonly INVALID_REQUEST: "INVALID_REQUEST";
|
||||
/**
|
||||
* Catch-all for situations the SDK couldn't classify into a more
|
||||
* specific code — present so consumers always have something to switch
|
||||
* on rather than falling back to message regex.
|
||||
*/
|
||||
readonly UNKNOWN: "UNKNOWN";
|
||||
};
|
||||
|
||||
export declare type VaultErrorCode = (typeof VaultErrorCode)[keyof typeof VaultErrorCode];
|
||||
|
||||
export { }
|
||||
|
||||
export as namespace EncryptionClient;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
export async function fetchRegisteredKeys(
|
||||
client: VaultClient,
|
||||
userIds: string[],
|
||||
): Promise<{
|
||||
publicKeys: Record<string, ArrayBuffer>;
|
||||
versions: Record<string, number>;
|
||||
}> {
|
||||
const users = await client.fetchPublicKeys(userIds);
|
||||
const publicKeys: Record<string, ArrayBuffer> = {};
|
||||
const versions: Record<string, number> = {};
|
||||
|
||||
for (const [id, u] of Object.entries(users)) {
|
||||
if (u.encryptionPublicKey) {
|
||||
publicKeys[id] = u.encryptionPublicKey;
|
||||
versions[id] = u.version;
|
||||
}
|
||||
}
|
||||
|
||||
return { publicKeys, versions };
|
||||
}
|
||||
+14
-100
@@ -1,107 +1,21 @@
|
||||
// Re-export types from encryption-client.d.ts for global availability
|
||||
// Ambient bindings for the VaultClient SDK.
|
||||
//
|
||||
// The full method/type surface lives in ./client-sdk.d.ts, a VERBATIM copy of
|
||||
// the encryption service's generated declaration (served at
|
||||
// <encryption-domain>/public-assets/client.d.ts). Regenerate it with:
|
||||
// curl http://encryption.localhost:7200/public-assets/client.d.ts -o client-sdk.d.ts
|
||||
// This file only re-exposes those types as the globals this codebase uses; never
|
||||
// hand-edit signatures here — fix them at the source and re-vendor.
|
||||
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>,
|
||||
options?: { optimizeMemory?: boolean },
|
||||
): Promise<{
|
||||
encryptedContent: ArrayBuffer;
|
||||
encryptedKeys: Record<string, ArrayBuffer>;
|
||||
}>;
|
||||
encryptWithKey(
|
||||
data: ArrayBuffer,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
encryptedKeyChain?: ArrayBuffer[],
|
||||
options?: { optimizeMemory?: boolean },
|
||||
): Promise<{ encryptedData: ArrayBuffer }>;
|
||||
decryptWithKey(
|
||||
encryptedData: ArrayBuffer,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
encryptedKeyChain?: ArrayBuffer[],
|
||||
options?: { optimizeMemory?: boolean },
|
||||
): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable error codes carried by `VaultError`. Sourced from the
|
||||
* encryption SDK (re-exported on `window.EncryptionClient.VaultErrorCode`)
|
||||
* — docs consumers match on these via `(err as VaultError).code` rather
|
||||
* than regexing message text. Keep in sync with the SDK definition.
|
||||
*/
|
||||
type VaultErrorCode =
|
||||
| 'MISSING_KEYS'
|
||||
| 'WRONG_SECRET_KEY'
|
||||
| 'INVALID_BACKUP'
|
||||
| 'INVALID_MNEMONIC'
|
||||
| 'NOT_INITIALIZED'
|
||||
| 'AUTH_REQUIRED'
|
||||
| 'PRIVILEGED_ORIGIN_REQUIRED'
|
||||
| 'TIMEOUT'
|
||||
| 'IFRAME_REQUIRED'
|
||||
| 'CIPHERTEXT_TOO_SHORT'
|
||||
| 'UNKNOWN';
|
||||
|
||||
interface VaultError extends Error {
|
||||
readonly code: VaultErrorCode;
|
||||
}
|
||||
type VaultClient = import('./client-sdk').VaultClient;
|
||||
type VaultError = import('./client-sdk').VaultError;
|
||||
type VaultErrorCode = import('./client-sdk').VaultErrorCode;
|
||||
type RegisteredUser = import('./client-sdk').RegisteredUser;
|
||||
type RecipientLabel = import('./client-sdk').RecipientLabel;
|
||||
|
||||
interface Window {
|
||||
EncryptionClient: {
|
||||
VaultClient: new (options: {
|
||||
vaultUrl: string;
|
||||
interfaceUrl: string;
|
||||
timeout?: number;
|
||||
theme?: string;
|
||||
lang?: string;
|
||||
}) => VaultClient;
|
||||
VaultError: new (code: VaultErrorCode, message: string) => VaultError;
|
||||
VaultErrorCode: { readonly [K in VaultErrorCode]: K };
|
||||
isVaultError: (err: unknown) => err is VaultError;
|
||||
};
|
||||
EncryptionClient: typeof import('./client-sdk');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { VaultClientProvider, useVaultClient } from './VaultClientProvider';
|
||||
export type { VaultClientContextValue } from './VaultClientProvider';
|
||||
export { fetchRegisteredKeys } from './fetchRegisteredKeys';
|
||||
|
||||
+10
-4
@@ -78,9 +78,12 @@ describe('DocEditor', () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { rerender } = render(<DocEditor doc={doc} documentEncryptionSettings={null} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
const { rerender } = render(
|
||||
<DocEditor doc={doc} documentEncryptionSettings={null} />,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
expect(TrackEventMock).toHaveBeenCalledWith({
|
||||
eventName: 'doc',
|
||||
@@ -90,7 +93,10 @@ describe('DocEditor', () => {
|
||||
|
||||
// Rerender with same doc to check that event is not tracked again
|
||||
rerender(
|
||||
<DocEditor doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }} documentEncryptionSettings={null} />,
|
||||
<DocEditor
|
||||
doc={{ ...doc, computed_link_reach: LinkReach.RESTRICTED }}
|
||||
documentEncryptionSettings={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(TrackEventMock).toHaveBeenNthCalledWith(1, {
|
||||
|
||||
+12
-5
@@ -19,8 +19,8 @@ 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 { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import {
|
||||
Doc,
|
||||
SwitchableProvider,
|
||||
@@ -41,9 +41,9 @@ import { DocsBlockNoteEditor } from '../types';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { EncryptedDocBanner } from './EncryptedDocBanner';
|
||||
import { EncryptionProvider } from './EncryptionProvider';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { cssComments, useComments } from './comments/';
|
||||
import {
|
||||
AccessibleImageBlock,
|
||||
@@ -119,8 +119,12 @@ export const BlockNoteEditor = ({
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
const encryptedSymmetricKey = documentEncryptionSettings?.encryptedSymmetricKey;
|
||||
const { uploadFile, errorAttachment } = useUploadFile(doc.id, encryptedSymmetricKey);
|
||||
const encryptedSymmetricKey =
|
||||
documentEncryptionSettings?.encryptedSymmetricKey;
|
||||
const { uploadFile, errorAttachment } = useUploadFile(
|
||||
doc.id,
|
||||
encryptedSymmetricKey,
|
||||
);
|
||||
|
||||
const collabName = user?.full_name || user?.email;
|
||||
const cursorName = collabName || t('Anonymous');
|
||||
@@ -248,7 +252,10 @@ export const BlockNoteEditor = ({
|
||||
}, [setEditor, editor]);
|
||||
|
||||
return (
|
||||
<EncryptionProvider encryptedSymmetricKey={encryptedSymmetricKey}>
|
||||
<EncryptionProvider
|
||||
encryptedSymmetricKey={encryptedSymmetricKey}
|
||||
keyVersion={documentEncryptionSettings?.keyVersion}
|
||||
>
|
||||
<EncryptedDocBanner />
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
|
||||
+8
-5
@@ -61,11 +61,13 @@ const EncryptionContext = createContext<EncryptionContextValue>(DEFAULT_VALUE);
|
||||
|
||||
interface EncryptionProviderProps {
|
||||
encryptedSymmetricKey: ArrayBuffer | undefined;
|
||||
keyVersion: number | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const EncryptionProvider = ({
|
||||
encryptedSymmetricKey,
|
||||
keyVersion,
|
||||
children,
|
||||
}: EncryptionProviderProps) => {
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
@@ -111,6 +113,7 @@ export const EncryptionProvider = ({
|
||||
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
|
||||
encryptedBuffer,
|
||||
encryptedSymmetricKey,
|
||||
keyVersion ?? 1,
|
||||
);
|
||||
|
||||
const ext = url.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -122,15 +125,15 @@ export const EncryptionProvider = ({
|
||||
|
||||
return blobUrl;
|
||||
},
|
||||
[encryptedSymmetricKey, vaultClient],
|
||||
[encryptedSymmetricKey, keyVersion, vaultClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const blobUrlCache = blobUrlCacheRef.current;
|
||||
|
||||
return () => {
|
||||
blobUrlCacheRef.current.forEach((blobUrl) =>
|
||||
URL.revokeObjectURL(blobUrl),
|
||||
);
|
||||
blobUrlCacheRef.current.clear();
|
||||
blobUrlCache.forEach((blobUrl) => URL.revokeObjectURL(blobUrl));
|
||||
blobUrlCache.clear();
|
||||
};
|
||||
}, [encryptedSymmetricKey]);
|
||||
|
||||
|
||||
+13
-3
@@ -219,9 +219,13 @@ const ImageBlockComponent = ({
|
||||
const showEncryptedPlaceholder =
|
||||
isEncrypted && (showClickPlaceholder || hasError) && !resolvedUrl;
|
||||
|
||||
// ResizableFileBlockWrapper's props type is internal to @blocknote/react and not exported.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const wrapperProps = { editor, block, ...rest } as any;
|
||||
|
||||
return (
|
||||
<ResizableFileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
{...wrapperProps}
|
||||
buttonIcon={
|
||||
<Icon iconName="image" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
@@ -286,9 +290,15 @@ export const AccessibleImageBlock = createReactBlockSpec(
|
||||
meta: {
|
||||
fileBlockAccept: ['image/*'],
|
||||
},
|
||||
render: (props) => <ImageBlockComponent {...(props as any)} />,
|
||||
render: (props) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<ImageBlockComponent {...(props as any)} />
|
||||
),
|
||||
parse: imageParse(config),
|
||||
toExternalHTML: (props) => <ImageToExternalHTML {...(props as any)} />,
|
||||
toExternalHTML: (props) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<ImageToExternalHTML {...(props as any)} />
|
||||
),
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
|
||||
+13
-3
@@ -45,9 +45,13 @@ const AudioBlockComponent = ({
|
||||
resolvedUrl,
|
||||
} = useDecryptMedia(block.props.url);
|
||||
|
||||
// FileBlockWrapper's props type is internal to @blocknote/react and not exported.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const wrapperProps = { editor, block, ...rest } as any;
|
||||
|
||||
return (
|
||||
<FileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
{...wrapperProps}
|
||||
buttonIcon={
|
||||
<Icon iconName="audiotrack" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
@@ -97,9 +101,15 @@ export const AudioBlock = createReactBlockSpec(
|
||||
meta: {
|
||||
fileBlockAccept: ['audio/*'],
|
||||
},
|
||||
render: (props) => <AudioBlockComponent {...(props as any)} />,
|
||||
render: (props) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<AudioBlockComponent {...(props as any)} />
|
||||
),
|
||||
parse: audioParse(config),
|
||||
toExternalHTML: (props) => <AudioToExternalHTML {...(props as any)} />,
|
||||
toExternalHTML: (props) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<AudioToExternalHTML {...(props as any)} />
|
||||
),
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
|
||||
+13
-3
@@ -48,9 +48,13 @@ const VideoBlockComponent = ({
|
||||
resolvedUrl,
|
||||
} = useDecryptMedia(block.props.url);
|
||||
|
||||
// ResizableFileBlockWrapper's props type is internal to @blocknote/react and not exported.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const wrapperProps = { editor, block, ...rest } as any;
|
||||
|
||||
return (
|
||||
<ResizableFileBlockWrapper
|
||||
{...({ editor, block, ...rest } as any)}
|
||||
{...wrapperProps}
|
||||
buttonIcon={
|
||||
<Icon iconName="videocam" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
@@ -100,9 +104,15 @@ export const VideoBlock = createReactBlockSpec(
|
||||
meta: {
|
||||
fileBlockAccept: ['video/*'],
|
||||
},
|
||||
render: (props) => <VideoBlockComponent {...(props as any)} />,
|
||||
render: (props) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<VideoBlockComponent {...(props as any)} />
|
||||
),
|
||||
parse: videoParse(config),
|
||||
toExternalHTML: (props) => <VideoToExternalHTML {...(props as any)} />,
|
||||
toExternalHTML: (props) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<VideoToExternalHTML {...(props as any)} />
|
||||
),
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
|
||||
+9
-3
@@ -20,6 +20,9 @@ vi.mock('@/docs/doc-management', async () => ({
|
||||
useUpdateDoc: (
|
||||
await vi.importActual('@/docs/doc-management/api/useUpdateDoc')
|
||||
).useUpdateDoc,
|
||||
// useSaveDoc reads `encryptionTransition` from the provider store; no
|
||||
// transition is the default (normal save path these tests exercise).
|
||||
useProviderStore: () => ({ encryptionTransition: null }),
|
||||
}));
|
||||
|
||||
describe('useSaveDoc', () => {
|
||||
@@ -132,9 +135,12 @@ describe('useSaveDoc', () => {
|
||||
const docId = 'test-doc-id';
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, false, null), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
const { unmount } = renderHook(
|
||||
() => useSaveDoc(docId, yDoc, true, false, null),
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { useUpdateDoc, useProviderStore } from '@/docs/doc-management/';
|
||||
import { useProviderStore, useUpdateDoc } 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';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, HorizontalSeparator } from '@/components';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import {
|
||||
Doc,
|
||||
LinkReach,
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
usePublicKeyRegistry,
|
||||
useUserEncryption,
|
||||
} from '@/docs/doc-collaboration';
|
||||
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import Export from '@/docs/doc-export/';
|
||||
import {
|
||||
Doc,
|
||||
@@ -31,11 +36,6 @@ 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,
|
||||
@@ -340,7 +340,9 @@ export const DocToolBox = ({
|
||||
documentEncryptionSettings?.encryptedSymmetricKey && (
|
||||
<ModalRemoveDocEncryption
|
||||
doc={doc}
|
||||
encryptedSymmetricKey={documentEncryptionSettings.encryptedSymmetricKey}
|
||||
encryptedSymmetricKey={
|
||||
documentEncryptionSettings.encryptedSymmetricKey
|
||||
}
|
||||
onClose={() => setIsModalRemoveEncryptionOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface EncryptDocProps {
|
||||
docId: string;
|
||||
content: Uint8Array<ArrayBufferLike>;
|
||||
encryptedSymmetricKeyPerUser: Record<string, string | null>;
|
||||
encryptionPublicKeyFingerprintPerUser: Record<string, string | null>;
|
||||
encryptionPublicKeyVersionPerUser: Record<string, number | null>;
|
||||
attachmentKeyMapping?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export const encryptDoc = async ({
|
||||
body: JSON.stringify({
|
||||
content: toBase64(params.content),
|
||||
encryptedSymmetricKeyPerUser: params.encryptedSymmetricKeyPerUser,
|
||||
encryptionPublicKeyFingerprintPerUser:
|
||||
params.encryptionPublicKeyFingerprintPerUser,
|
||||
encryptionPublicKeyVersionPerUser:
|
||||
params.encryptionPublicKeyVersionPerUser,
|
||||
attachmentKeyMapping: params.attachmentKeyMapping || {},
|
||||
}),
|
||||
});
|
||||
|
||||
+35
-39
@@ -2,8 +2,11 @@ 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 {
|
||||
fetchRegisteredKeys,
|
||||
useVaultClient,
|
||||
} from '@/features/docs/doc-collaboration/vault';
|
||||
|
||||
import type { Doc } from '../types';
|
||||
|
||||
@@ -20,16 +23,18 @@ import type { Doc } from '../types';
|
||||
export const isWrongSecretKeyError = (
|
||||
err: Error | null | undefined,
|
||||
): boolean => {
|
||||
if (!err) return false;
|
||||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
return (err as VaultError).code === 'WRONG_SECRET_KEY';
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The doc — used to read the share-time fingerprint from
|
||||
* `doc.accesses_fingerprints_per_user[currentUser.suite_user_id]`.
|
||||
* The doc — used to read the share-time encryption key version from
|
||||
* `doc.accesses_versions_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`
|
||||
* later collapse it to a single `encryption_public_key_version_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.)
|
||||
*/
|
||||
@@ -39,49 +44,40 @@ interface Props {
|
||||
/**
|
||||
* 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.
|
||||
* share-time key version (from the doc's version map) AND the user's
|
||||
* current key version so whoever re-shares can see the access was
|
||||
* wrapped for an older key and needs re-encryption.
|
||||
*/
|
||||
export const KeyMismatchPanel = ({ doc }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const [currentFingerprint, setCurrentFingerprint] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [currentVersion, setCurrentVersion] = useState<number | null>(null);
|
||||
|
||||
const shareTimeFingerprint = user?.suite_user_id
|
||||
? (doc.accesses_fingerprints_per_user?.[user.suite_user_id] ?? null)
|
||||
const shareTimeVersion = user?.suite_user_id
|
||||
? (doc.accesses_versions_per_user?.[user.suite_user_id] ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaultClient) return;
|
||||
if (!vaultClient || !user?.suite_user_id) {
|
||||
return;
|
||||
}
|
||||
const sub = user.suite_user_id;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { publicKey } = await vaultClient.getPublicKey();
|
||||
const raw = await vaultClient.computeKeyFingerprint(publicKey);
|
||||
const formatted = vaultClient.formatFingerprint(raw);
|
||||
if (!cancelled) setCurrentFingerprint(formatted);
|
||||
const { versions } = await fetchRegisteredKeys(vaultClient, [sub]);
|
||||
if (!cancelled) {
|
||||
setCurrentVersion(versions[sub] ?? null);
|
||||
}
|
||||
} catch {
|
||||
// Ignore — we just won't render the fingerprint row.
|
||||
// Ignore — we just won't render the version row.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [vaultClient]);
|
||||
|
||||
const formattedShareTime = (() => {
|
||||
if (!shareTimeFingerprint) return null;
|
||||
if (!vaultClient) return shareTimeFingerprint;
|
||||
try {
|
||||
return vaultClient.formatFingerprint(shareTimeFingerprint);
|
||||
} catch {
|
||||
return shareTimeFingerprint;
|
||||
}
|
||||
})();
|
||||
}, [vaultClient, user?.suite_user_id]);
|
||||
|
||||
return (
|
||||
<Box $align="center" $margin="auto" $gap="md" $padding="2rem">
|
||||
@@ -92,15 +88,15 @@ export const KeyMismatchPanel = ({ doc }: Props) => {
|
||||
<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.",
|
||||
'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) && (
|
||||
{(shareTimeVersion !== null || currentVersion !== null) && (
|
||||
<Box $gap="2xs" $maxWidth="500px" $align="center">
|
||||
{formattedShareTime && (
|
||||
{shareTimeVersion !== null && (
|
||||
<Text $variation="secondary" $size="sm" $textAlign="center">
|
||||
{t('Fingerprint at the time it was shared with you:')}{' '}
|
||||
{t('Encryption key version at the time it was shared with you:')}{' '}
|
||||
<Text
|
||||
as="span"
|
||||
$css={`
|
||||
@@ -110,13 +106,13 @@ export const KeyMismatchPanel = ({ doc }: Props) => {
|
||||
border-radius: 3px;
|
||||
`}
|
||||
>
|
||||
{formattedShareTime}
|
||||
{shareTimeVersion}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
{currentFingerprint && (
|
||||
{currentVersion !== null && (
|
||||
<Text $variation="secondary" $size="sm" $textAlign="center">
|
||||
{t('Your current key fingerprint:')}{' '}
|
||||
{t('Your current encryption key version:')}{' '}
|
||||
<Text
|
||||
as="span"
|
||||
$css={`
|
||||
@@ -126,7 +122,7 @@ export const KeyMismatchPanel = ({ doc }: Props) => {
|
||||
border-radius: 3px;
|
||||
`}
|
||||
>
|
||||
{currentFingerprint}
|
||||
{currentVersion}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
+86
-39
@@ -6,16 +6,20 @@ import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Spinner } from '@gouvfr-lasuite/ui-kit';
|
||||
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 {
|
||||
fetchRegisteredKeys,
|
||||
useVaultClient,
|
||||
} from '@/features/docs/doc-collaboration/vault';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
import {
|
||||
Doc,
|
||||
EncryptionTransitionEvent,
|
||||
@@ -30,7 +34,6 @@ import {
|
||||
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:
|
||||
@@ -155,19 +158,32 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
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>>({});
|
||||
const [publicKeysMap, setPublicKeysMap] = useState<
|
||||
Record<string, ArrayBuffer>
|
||||
>({});
|
||||
const [keyVersionsMap, setKeyVersionsMap] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accesses || !vaultClient) return;
|
||||
if (!accesses || !vaultClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userIds = accesses
|
||||
.filter((a) => a.user?.suite_user_id)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.map((a) => a.user.suite_user_id!);
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
if (userIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
vaultClient.fetchPublicKeys(userIds)
|
||||
.then(({ publicKeys }) => setPublicKeysMap(publicKeys))
|
||||
fetchRegisteredKeys(vaultClient, userIds)
|
||||
.then(({ publicKeys, versions }) => {
|
||||
setPublicKeysMap(publicKeys);
|
||||
setKeyVersionsMap(versions);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [accesses, vaultClient]);
|
||||
|
||||
@@ -177,10 +193,23 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
}
|
||||
|
||||
return accesses.filter(
|
||||
(access) => access.user?.suite_user_id && !publicKeysMap[access.user.suite_user_id],
|
||||
(access) =>
|
||||
access.user?.suite_user_id && !publicKeysMap[access.user.suite_user_id],
|
||||
);
|
||||
}, [accesses, publicKeysMap]);
|
||||
|
||||
// The current user is the one performing the encryption — never surface them
|
||||
// in the "haven't completed onboarding" summary, even if their own key has
|
||||
// not yet propagated to the directory. The backend write path still relies on
|
||||
// `membersWithoutKey` above.
|
||||
const othersWithoutKey = useMemo(
|
||||
() =>
|
||||
membersWithoutKey.filter(
|
||||
(access) => access.user?.suite_user_id !== user?.suite_user_id,
|
||||
),
|
||||
[membersWithoutKey, user?.suite_user_id],
|
||||
);
|
||||
|
||||
const hasEncryptionKeys = !!encryptionSettings;
|
||||
|
||||
// Members with no public key will be written to the backend as
|
||||
@@ -192,8 +221,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
accesses === undefined || accesses.length === 0
|
||||
? true
|
||||
: accesses.some(
|
||||
(a) =>
|
||||
a.user?.suite_user_id && !!publicKeysMap[a.user.suite_user_id],
|
||||
(a) => a.user?.suite_user_id && !!publicKeysMap[a.user.suite_user_id],
|
||||
);
|
||||
|
||||
const canEncrypt =
|
||||
@@ -210,7 +238,14 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
};
|
||||
|
||||
const handleEncrypt = async () => {
|
||||
if (!provider || !user || isPending || !canEncrypt || !encryptionSettings || !vaultClient) {
|
||||
if (
|
||||
!provider ||
|
||||
!user ||
|
||||
isPending ||
|
||||
!canEncrypt ||
|
||||
!encryptionSettings ||
|
||||
!vaultClient
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,7 +255,9 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED);
|
||||
|
||||
if (Object.keys(publicKeysMap).length === 0) {
|
||||
throw new Error('No public keys available. All members must have encryption enabled.');
|
||||
throw new Error(
|
||||
'No public keys available. All members must have encryption enabled.',
|
||||
);
|
||||
}
|
||||
|
||||
// Clone the Yjs document for encryption
|
||||
@@ -229,11 +266,27 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
|
||||
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
|
||||
|
||||
// Encrypt document content via vault — pure ArrayBuffer
|
||||
// Encrypt document content via vault — pure ArrayBuffer. Pass a labeled
|
||||
// recipient map (sub → {email, name}) for every member that has a
|
||||
// published key: the vault resolves + trust-checks each key itself
|
||||
// (binding + TOFU) before wrapping, and the labels are display-only,
|
||||
// surfaced if the trust modal needs a decision. Emails are joined back
|
||||
// from `accesses` (publicKeysMap only carries sub → key).
|
||||
const recipients: Record<string, { email: string; name?: string }> = {};
|
||||
for (const access of accesses ?? []) {
|
||||
const sub = access.user?.suite_user_id;
|
||||
if (sub && publicKeysMap[sub]) {
|
||||
recipients[sub] = {
|
||||
email: access.user.email,
|
||||
name: access.user.full_name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { encryptedContent: encryptedContentBuffer, encryptedKeys } =
|
||||
await vaultClient.encryptWithoutKey(
|
||||
ongoingDocState.buffer as ArrayBuffer,
|
||||
publicKeysMap,
|
||||
recipients,
|
||||
);
|
||||
|
||||
// Contract with /encrypt/: every user on the access list must
|
||||
@@ -252,31 +305,25 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Matched fingerprint map — same set of users, same cardinality.
|
||||
// Matched version 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;
|
||||
}
|
||||
// later detect key rotation (current version !== stored version).
|
||||
const encryptionPublicKeyVersionPerUser: Record<string, number | null> =
|
||||
{};
|
||||
for (const uid of Object.keys(publicKeysMap)) {
|
||||
encryptionPublicKeyVersionPerUser[uid] = keyVersionsMap[uid] ?? null;
|
||||
}
|
||||
for (const access of membersWithoutKey) {
|
||||
const sub = access.user?.suite_user_id;
|
||||
if (sub && !(sub in encryptionPublicKeyFingerprintPerUser)) {
|
||||
encryptionPublicKeyFingerprintPerUser[sub] = null;
|
||||
if (sub && !(sub in encryptionPublicKeyVersionPerUser)) {
|
||||
encryptionPublicKeyVersionPerUser[sub] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current user's encrypted key for attachment encryption
|
||||
const currentUserEncryptedKey = user.suite_user_id ? encryptedKeys[user.suite_user_id] : undefined;
|
||||
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> = {};
|
||||
@@ -298,7 +345,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
docId: doc.id,
|
||||
content: encryptedContent,
|
||||
encryptedSymmetricKeyPerUser,
|
||||
encryptionPublicKeyFingerprintPerUser,
|
||||
encryptionPublicKeyVersionPerUser,
|
||||
attachmentKeyMapping,
|
||||
});
|
||||
|
||||
@@ -470,27 +517,27 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
<Box $direction="row" $align="center" $gap="xs">
|
||||
<Icon
|
||||
iconName={
|
||||
membersWithoutKey.length === 0
|
||||
othersWithoutKey.length === 0
|
||||
? 'check_circle'
|
||||
: 'hourglass_empty'
|
||||
}
|
||||
$size="sm"
|
||||
$theme={
|
||||
membersWithoutKey.length === 0 ? 'success' : 'warning'
|
||||
othersWithoutKey.length === 0 ? 'success' : 'warning'
|
||||
}
|
||||
/>
|
||||
<Text $size="sm">
|
||||
{membersWithoutKey.length === 0
|
||||
{othersWithoutKey.length === 0
|
||||
? t('All members have encryption enabled')
|
||||
: t(
|
||||
'{{count}} member(s) haven’t completed encryption onboarding yet. They will be added as pending and won’t be able to decrypt the document until another validated collaborator accepts them from the share dialog.',
|
||||
{ count: membersWithoutKey.length },
|
||||
{ count: othersWithoutKey.length },
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
{membersWithoutKey.length > 0 && (
|
||||
{othersWithoutKey.length > 0 && (
|
||||
<Box $margin={{ left: 'sm' }} $gap="3xs">
|
||||
{membersWithoutKey.map((access) => (
|
||||
{othersWithoutKey.map((access) => (
|
||||
<Text key={access.id} $size="xs" $variation="secondary">
|
||||
{access.user.full_name || access.user.email}
|
||||
</Text>
|
||||
|
||||
+13
-2
@@ -12,6 +12,8 @@ import * as Y from 'yjs';
|
||||
|
||||
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
|
||||
import { createDocAttachment } from '@/docs/doc-editor/api';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import {
|
||||
Doc,
|
||||
EncryptionTransitionEvent,
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
useProviderStore,
|
||||
useRemoveDocEncryption,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
|
||||
import { useKeyboardAction } from '@/hooks';
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ const decryptRemoteAttachments = async (
|
||||
docId: string,
|
||||
vaultClient: VaultClient,
|
||||
encryptedSymmetricKey: ArrayBuffer,
|
||||
keyVersion: number,
|
||||
): Promise<Record<string, string>> => {
|
||||
const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc);
|
||||
|
||||
@@ -57,6 +59,7 @@ const decryptRemoteAttachments = async (
|
||||
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
|
||||
encryptedBuffer,
|
||||
encryptedSymmetricKey,
|
||||
keyVersion,
|
||||
);
|
||||
|
||||
const fileName = oldAttachmentMetadata.name ?? 'file';
|
||||
@@ -113,6 +116,7 @@ export const ModalRemoveDocEncryption = ({
|
||||
const { provider, notifyOthers, startEncryptionTransition } =
|
||||
useProviderStore();
|
||||
const { client: vaultClient } = useVaultClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
@@ -151,6 +155,9 @@ export const ModalRemoveDocEncryption = ({
|
||||
doc.id,
|
||||
vaultClient,
|
||||
encryptedSymmetricKey,
|
||||
(user?.suite_user_id
|
||||
? doc.accesses_versions_per_user?.[user.suite_user_id]
|
||||
: undefined) ?? 1,
|
||||
);
|
||||
|
||||
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
|
||||
@@ -184,7 +191,11 @@ export const ModalRemoveDocEncryption = ({
|
||||
size={ModalSize.MEDIUM}
|
||||
rightActions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={isPending}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
+3
-1
@@ -45,8 +45,9 @@ export const useCollaboration = (
|
||||
if (initialDocState) {
|
||||
// Decrypt initial document content via vault — pure ArrayBuffer
|
||||
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
|
||||
initialDocState.buffer as ArrayBuffer,
|
||||
initialDocState.buffer,
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
documentEncryptionSettings.keyVersion,
|
||||
);
|
||||
|
||||
decryptedState = Buffer.from(decryptedBuffer);
|
||||
@@ -60,6 +61,7 @@ export const useCollaboration = (
|
||||
vaultClient,
|
||||
encryptedSymmetricKey:
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
keyVersion: documentEncryptionSettings.keyVersion,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+7
-3
@@ -30,6 +30,7 @@ export interface UseCollaborationStore {
|
||||
encryptionOptions?: {
|
||||
vaultClient: VaultClient;
|
||||
encryptedSymmetricKey: ArrayBuffer;
|
||||
keyVersion: number;
|
||||
},
|
||||
) => SwitchableProvider;
|
||||
destroyProvider: () => void;
|
||||
@@ -103,8 +104,9 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
//
|
||||
|
||||
const AdaptedEncryptedWebSocket = createAdaptedEncryptedWebsocketClass({
|
||||
vaultClient: encryptionOptions!.vaultClient,
|
||||
encryptedSymmetricKey: encryptionOptions!.encryptedSymmetricKey,
|
||||
vaultClient: encryptionOptions.vaultClient,
|
||||
encryptedSymmetricKey: encryptionOptions.encryptedSymmetricKey,
|
||||
keyVersion: encryptionOptions.keyVersion,
|
||||
onSystemMessage: (message) => {
|
||||
if (message === 'system:authenticated') {
|
||||
set({ isReady: true, isConnected: true });
|
||||
@@ -117,7 +119,9 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
// text — the SDK guarantees `code === 'WRONG_SECRET_KEY'`
|
||||
// for the AEAD-verification failure branch (libsodium's
|
||||
// "wrong secret key for the given ciphertext").
|
||||
if ((err as VaultError | null | undefined)?.code === 'WRONG_SECRET_KEY') {
|
||||
if (
|
||||
(err as VaultError | null | undefined)?.code === 'WRONG_SECRET_KEY'
|
||||
) {
|
||||
set({ decryptionFailed: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface Doc {
|
||||
user_role: Role;
|
||||
encrypted_document_symmetric_key_for_user?: string;
|
||||
accesses_user_ids?: string[];
|
||||
accesses_fingerprints_per_user?: Record<string, string>;
|
||||
accesses_versions_per_user?: Record<string, number>;
|
||||
abilities: {
|
||||
accesses_manage: boolean;
|
||||
accesses_view: boolean;
|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@ interface AcceptEncryptionAccessParams {
|
||||
docId: Doc['id'];
|
||||
accessId: string;
|
||||
encrypted_document_symmetric_key_for_user: string;
|
||||
encryption_public_key_fingerprint: string;
|
||||
encryption_public_key_version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,13 +19,13 @@ interface AcceptEncryptionAccessParams {
|
||||
* 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.
|
||||
* to the supplied wrapped key and stores the current key version.
|
||||
*/
|
||||
export const acceptEncryptionAccess = async ({
|
||||
docId,
|
||||
accessId,
|
||||
encrypted_document_symmetric_key_for_user,
|
||||
encryption_public_key_fingerprint,
|
||||
encryption_public_key_version,
|
||||
}: AcceptEncryptionAccessParams): Promise<void> => {
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/accesses/${accessId}/encryption-key/`,
|
||||
@@ -33,7 +33,7 @@ export const acceptEncryptionAccess = async ({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
encrypted_document_symmetric_key_for_user,
|
||||
encryption_public_key_fingerprint,
|
||||
encryption_public_key_version,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ interface CreateDocAccessParams {
|
||||
docId: Doc['id'];
|
||||
memberId: User['id'];
|
||||
memberEncryptedSymmetricKey: string | null;
|
||||
encryptionPublicKeyFingerprint?: string | null;
|
||||
encryptionPublicKeyVersion?: number | null;
|
||||
}
|
||||
|
||||
export const createDocAccess = async ({
|
||||
@@ -29,7 +29,7 @@ export const createDocAccess = async ({
|
||||
role,
|
||||
docId,
|
||||
memberEncryptedSymmetricKey,
|
||||
encryptionPublicKeyFingerprint,
|
||||
encryptionPublicKeyVersion,
|
||||
}: CreateDocAccessParams): Promise<Access> => {
|
||||
const response = await fetchAPI(`documents/${docId}/accesses/`, {
|
||||
method: 'POST',
|
||||
@@ -37,8 +37,8 @@ export const createDocAccess = async ({
|
||||
user_id: memberId,
|
||||
role,
|
||||
encrypted_document_symmetric_key_for_user: memberEncryptedSymmetricKey,
|
||||
...(encryptionPublicKeyFingerprint && {
|
||||
encryption_public_key_fingerprint: encryptionPublicKeyFingerprint,
|
||||
...(encryptionPublicKeyVersion != null && {
|
||||
encryption_public_key_version: encryptionPublicKeyVersion,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
+27
-10
@@ -9,11 +9,14 @@ 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 {
|
||||
fetchRegisteredKeys,
|
||||
useVaultClient,
|
||||
} from '@/features/docs/doc-collaboration/vault';
|
||||
import { toBase64 } from '@/features/docs/doc-editor';
|
||||
|
||||
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
|
||||
import { OptionType } from '../types';
|
||||
@@ -92,15 +95,21 @@ export const DocShareAddMemberList = ({
|
||||
|
||||
// Fetch all public keys in a single request before processing users
|
||||
let publicKeysMap: Record<string, ArrayBuffer> = {};
|
||||
let keyVersionsMap: Record<string, number> = {};
|
||||
|
||||
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
|
||||
const memberUserIds = selectedUsers
|
||||
.filter((user) => user.id !== user.email && user.suite_user_id)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.map((user) => user.suite_user_id!);
|
||||
|
||||
if (memberUserIds.length > 0) {
|
||||
const { publicKeys } = await vaultClient.fetchPublicKeys(memberUserIds);
|
||||
const { publicKeys, versions } = await fetchRegisteredKeys(
|
||||
vaultClient,
|
||||
memberUserIds,
|
||||
);
|
||||
publicKeysMap = publicKeys;
|
||||
keyVersionsMap = versions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,15 +148,22 @@ export const DocShareAddMemberList = ({
|
||||
|
||||
// For encrypted docs, re-wrap the symmetric key for the new member via vault
|
||||
let memberEncryptedSymmetricKey: string | null = null;
|
||||
let encryptionPublicKeyFingerprint: string | null = null;
|
||||
let encryptionPublicKeyVersion: number | null = null;
|
||||
|
||||
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
|
||||
const userPublicKey = user.suite_user_id ? publicKeysMap[user.suite_user_id] : undefined;
|
||||
const userPublicKey = user.suite_user_id
|
||||
? publicKeysMap[user.suite_user_id]
|
||||
: undefined;
|
||||
|
||||
if (userPublicKey && user.suite_user_id) {
|
||||
// Pass a labeled recipient map (sub → {email, name}): the vault
|
||||
// resolves + trust-checks the key itself (binding + TOFU); the label
|
||||
// is display-only, shown if the trust modal needs a decision.
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
{ [user.suite_user_id]: userPublicKey },
|
||||
{
|
||||
[user.suite_user_id]: { email: user.email, name: user.full_name },
|
||||
},
|
||||
);
|
||||
|
||||
const wrappedKey = encryptedKeys[user.suite_user_id];
|
||||
@@ -155,9 +171,10 @@ export const DocShareAddMemberList = ({
|
||||
memberEncryptedSymmetricKey = toBase64(new Uint8Array(wrappedKey));
|
||||
}
|
||||
|
||||
// Store the recipient's public key fingerprint at share time
|
||||
encryptionPublicKeyFingerprint =
|
||||
await vaultClient.computeKeyFingerprint(userPublicKey);
|
||||
// Store the recipient's public key version at share time so a
|
||||
// later version bump signals the access needs re-encryption
|
||||
encryptionPublicKeyVersion =
|
||||
keyVersionsMap[user.suite_user_id] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +182,7 @@ export const DocShareAddMemberList = ({
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
memberEncryptedSymmetricKey,
|
||||
encryptionPublicKeyFingerprint,
|
||||
encryptionPublicKeyVersion,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
-3
@@ -93,12 +93,10 @@ export const DocShareInvitationItem = ({
|
||||
type DocShareModalInviteUserRowProps = {
|
||||
user: User;
|
||||
suffix?: string;
|
||||
fingerprintKey?: string | null;
|
||||
};
|
||||
export const DocShareModalInviteUserRow = ({
|
||||
user,
|
||||
suffix,
|
||||
fingerprintKey,
|
||||
}: DocShareModalInviteUserRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -110,7 +108,6 @@ export const DocShareModalInviteUserRow = ({
|
||||
<SearchUserRow
|
||||
user={user}
|
||||
suffix={suffix}
|
||||
fingerprintKey={fingerprintKey}
|
||||
right={
|
||||
<BoxButton
|
||||
className="right-hover"
|
||||
|
||||
+2
-10
@@ -25,7 +25,6 @@ type Props = {
|
||||
isInherited?: boolean;
|
||||
suffix?: string;
|
||||
onSuffixClick?: () => void;
|
||||
fingerprintKey?: string | null;
|
||||
};
|
||||
export const DocShareMemberItem = ({
|
||||
doc,
|
||||
@@ -33,7 +32,6 @@ export const DocShareMemberItem = ({
|
||||
isInherited = false,
|
||||
suffix,
|
||||
onSuffixClick,
|
||||
fingerprintKey,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isLastOwner } = useWhoAmI(access);
|
||||
@@ -79,7 +77,6 @@ export const DocShareMemberItem = ({
|
||||
user={access.user}
|
||||
suffix={suffix}
|
||||
onSuffixClick={onSuffixClick}
|
||||
fingerprintKey={fingerprintKey}
|
||||
right={
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
|
||||
<DocRoleDropdown
|
||||
@@ -147,7 +144,7 @@ export const QuickSearchGroupMember = ({
|
||||
const hasMismatch = uid ? keyMismatchUserIds?.has(uid) : false;
|
||||
const hasNoEncryptionKey =
|
||||
doc.is_encrypted &&
|
||||
(!uid || !doc.accesses_fingerprints_per_user?.[uid]);
|
||||
(!uid || !doc.accesses_versions_per_user?.[uid]);
|
||||
|
||||
let suffix: string | undefined;
|
||||
if (hasMismatch) {
|
||||
@@ -164,12 +161,7 @@ export const QuickSearchGroupMember = ({
|
||||
access={access}
|
||||
suffix={suffix}
|
||||
onSuffixClick={
|
||||
hasMismatch && uid
|
||||
? () => setMismatchUserId(uid)
|
||||
: undefined
|
||||
}
|
||||
fingerprintKey={
|
||||
uid ? doc.accesses_fingerprints_per_user?.[uid] : undefined
|
||||
hasMismatch && uid ? () => setMismatchUserId(uid) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
QuickSearchGroupAccessRequest,
|
||||
} from './DocShareAccessRequest';
|
||||
import { DocShareAddMemberList } from './DocShareAddMemberList';
|
||||
import { PendingEncryptionSection } from './PendingEncryptionSection';
|
||||
import {
|
||||
DocShareModalInviteUserRow,
|
||||
QuickSearchGroupInvitation,
|
||||
@@ -53,6 +52,7 @@ import {
|
||||
import { QuickSearchGroupMember } from './DocShareMember';
|
||||
import { DocShareModalFooter } from './DocShareModalFooter';
|
||||
import { ModalKeyMismatch } from './ModalKeyMismatch';
|
||||
import { PendingEncryptionSection } from './PendingEncryptionSection';
|
||||
|
||||
const ShareModalStyle = createGlobalStyle`
|
||||
.--docs--doc-share-modal [cmdk-item] {
|
||||
@@ -94,6 +94,9 @@ export const DocShareModal = ({
|
||||
} = useDocumentEncryption(
|
||||
needsDerivation ? doc.is_encrypted : undefined,
|
||||
needsDerivation ? doc.encrypted_document_symmetric_key_for_user : undefined,
|
||||
needsDerivation && user?.suite_user_id
|
||||
? doc.accesses_versions_per_user?.[user.suite_user_id]
|
||||
: undefined,
|
||||
);
|
||||
const effectiveEncryptionSettings =
|
||||
documentEncryptionSettings ?? derivedEncryptionSettings ?? null;
|
||||
@@ -479,7 +482,11 @@ const QuickSearchInviteInputSection = ({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(user: User) => {
|
||||
if (isEncrypted && (!user.suite_user_id || !doc.accesses_fingerprints_per_user?.[user.suite_user_id])) {
|
||||
if (
|
||||
isEncrypted &&
|
||||
(!user.suite_user_id ||
|
||||
!doc.accesses_versions_per_user?.[user.suite_user_id])
|
||||
) {
|
||||
setShowNoKeyModal(true);
|
||||
return;
|
||||
}
|
||||
@@ -489,7 +496,7 @@ const QuickSearchInviteInputSection = ({
|
||||
}
|
||||
onSelect(user);
|
||||
},
|
||||
[isEncrypted, doc.accesses_fingerprints_per_user, keyMismatchUserIds, onSelect],
|
||||
[isEncrypted, doc.accesses_versions_per_user, keyMismatchUserIds, onSelect],
|
||||
);
|
||||
|
||||
const searchUserData: QuickSearchData<User> = useMemo(() => {
|
||||
@@ -529,12 +536,16 @@ const QuickSearchInviteInputSection = ({
|
||||
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])) {
|
||||
if (
|
||||
isEncrypted &&
|
||||
(!user.suite_user_id ||
|
||||
!doc.accesses_versions_per_user?.[user.suite_user_id])
|
||||
) {
|
||||
return t(`(encryption not enabled)`);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[isEncrypted, doc.accesses_fingerprints_per_user, keyMismatchUserIds, t],
|
||||
[isEncrypted, doc.accesses_versions_per_user, keyMismatchUserIds, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -549,7 +560,6 @@ const QuickSearchInviteInputSection = ({
|
||||
<DocShareModalInviteUserRow
|
||||
user={user}
|
||||
suffix={getUserSuffix(user)}
|
||||
fingerprintKey={user.suite_user_id ? doc.accesses_fingerprints_per_user?.[user.suite_user_id] : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -614,9 +624,12 @@ const QuickSearchInviteInputSection = ({
|
||||
onAcceptKey={
|
||||
acceptNewKey
|
||||
? () => {
|
||||
void acceptNewKey(mismatchUser.suite_user_id!).then(() => {
|
||||
onSelect(mismatchUser);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
void acceptNewKey(mismatchUser.suite_user_id!).then(
|
||||
() => {
|
||||
onSelect(mismatchUser);
|
||||
},
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
+28
-13
@@ -3,10 +3,13 @@ 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 type { DocumentEncryptionSettings } from '@/features/docs/doc-collaboration/hook/useDocumentEncryption';
|
||||
import {
|
||||
fetchRegisteredKeys,
|
||||
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';
|
||||
|
||||
@@ -76,10 +79,11 @@ export const PendingEncryptionSection = ({
|
||||
setProbing(false);
|
||||
return;
|
||||
}
|
||||
vaultClient
|
||||
.fetchPublicKeys(subs)
|
||||
fetchRegisteredKeys(vaultClient, subs)
|
||||
.then(({ publicKeys }) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const sub of subs) {
|
||||
next[sub] = !!publicKeys[sub];
|
||||
@@ -90,7 +94,9 @@ export const PendingEncryptionSection = ({
|
||||
/* leave empty — fall back to "waiting for their onboarding" */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setProbing(false);
|
||||
if (!cancelled) {
|
||||
setProbing(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -99,11 +105,16 @@ export const PendingEncryptionSection = ({
|
||||
// unrelated access array identity changes.
|
||||
}, [pendingSubsSignature, vaultClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (pending.length === 0) return null;
|
||||
if (pending.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAccept = async (access: Access) => {
|
||||
const sub = access.user?.suite_user_id;
|
||||
if (!sub || !vaultClient || !documentEncryptionSettings) return;
|
||||
const recipient = access.user;
|
||||
const sub = recipient?.suite_user_id;
|
||||
if (!sub || !recipient || !vaultClient || !documentEncryptionSettings) {
|
||||
return;
|
||||
}
|
||||
setInFlight((prev) => new Set(prev).add(access.id));
|
||||
setErrorByAccessId((prev) => {
|
||||
const copy = { ...prev };
|
||||
@@ -111,7 +122,9 @@ export const PendingEncryptionSection = ({
|
||||
return copy;
|
||||
});
|
||||
try {
|
||||
const { publicKeys } = await vaultClient.fetchPublicKeys([sub]);
|
||||
const { publicKeys, versions } = await fetchRegisteredKeys(vaultClient, [
|
||||
sub,
|
||||
]);
|
||||
const userPublicKey = publicKeys[sub];
|
||||
if (!userPublicKey) {
|
||||
setHasPublicKeyBySub((m) => ({ ...m, [sub]: false }));
|
||||
@@ -119,22 +132,24 @@ export const PendingEncryptionSection = ({
|
||||
t("This user still hasn't completed their encryption onboarding."),
|
||||
);
|
||||
}
|
||||
// The vault resolves + trust-checks the recipient key (binding + TOFU);
|
||||
// the fetched userPublicKey above only gates on completed onboarding. The
|
||||
// label (email/name) is display-only, shown if the trust modal opens.
|
||||
const { encryptedKeys } = await vaultClient.shareKeys(
|
||||
documentEncryptionSettings.encryptedSymmetricKey,
|
||||
{ [sub]: userPublicKey },
|
||||
{ [sub]: { email: recipient.email, name: recipient.full_name } },
|
||||
);
|
||||
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,
|
||||
encryption_public_key_version: versions[sub],
|
||||
});
|
||||
} catch (err) {
|
||||
setErrorByAccessId((prev) => ({
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
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 = {
|
||||
@@ -17,7 +13,6 @@ type Props = {
|
||||
isInvitation?: boolean;
|
||||
suffix?: string;
|
||||
onSuffixClick?: () => void;
|
||||
fingerprintKey?: string | null;
|
||||
};
|
||||
|
||||
export const SearchUserRow = ({
|
||||
@@ -27,12 +22,9 @@ export const SearchUserRow = ({
|
||||
isInvitation = false,
|
||||
suffix,
|
||||
onSuffixClick,
|
||||
fingerprintKey,
|
||||
}: Props) => {
|
||||
const hasFullName = !!user.full_name;
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const fingerprint = useKeyFingerprint(fingerprintKey);
|
||||
|
||||
return (
|
||||
<QuickSearchItemContent
|
||||
@@ -81,30 +73,6 @@ export const SearchUserRow = ({
|
||||
{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>
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const DocsGrid = ({
|
||||
});
|
||||
}, [data?.pages]);
|
||||
|
||||
const { encryptionLoading, encryptionSettings } = useUserEncryption();
|
||||
const { encryptionLoading } = useUserEncryption();
|
||||
|
||||
const loading = isFetching || isLoading || encryptionLoading;
|
||||
const hasDocs = data?.pages.some((page) => page.results.length > 0);
|
||||
|
||||
@@ -20,7 +20,7 @@ export const Skeleton = ({ children }: PropsWithChildren) => {
|
||||
const { isSkeletonVisible } = useSkeletonStore();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [isVisible, setIsVisible] = useState(isSkeletonVisible);
|
||||
const [isFadingOut, setIsFadingOut] = useState(true);
|
||||
const [isFadingOut] = useState(true);
|
||||
const timeoutVisibleRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { TreeProvider } from '@gouvfr-lasuite/ui-kit';
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Spinner, TreeProvider } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Spinner } from '@gouvfr-lasuite/ui-kit';
|
||||
|
||||
import { Box, Icon, Loading, StyledLink, Text, TextErrors } from '@/components';
|
||||
import { DEFAULT_QUERY_RETRY } from '@/core';
|
||||
import { DocEditor } from '@/docs/doc-editor';
|
||||
import { KeyMismatchPanel } from '@/features/docs/doc-management/components/KeyMismatchPanel';
|
||||
import {
|
||||
Doc,
|
||||
DocPage403,
|
||||
@@ -27,6 +24,7 @@ import {
|
||||
useDocumentEncryption,
|
||||
useUserEncryption,
|
||||
} from '@/features/docs/doc-collaboration';
|
||||
import { KeyMismatchPanel } from '@/features/docs/doc-management/components/KeyMismatchPanel';
|
||||
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { MainLayout } from '@/layouts';
|
||||
@@ -102,7 +100,7 @@ const DocPage = ({ id }: DocProps) => {
|
||||
},
|
||||
);
|
||||
|
||||
const { authenticated } = useAuth();
|
||||
const { authenticated, user } = useAuth();
|
||||
const [doc, setDoc] = useState<Doc>();
|
||||
const { encryptionLoading, encryptionError } = useUserEncryption();
|
||||
const {
|
||||
@@ -112,6 +110,9 @@ const DocPage = ({ id }: DocProps) => {
|
||||
} = useDocumentEncryption(
|
||||
doc?.is_encrypted,
|
||||
doc?.encrypted_document_symmetric_key_for_user,
|
||||
user?.suite_user_id
|
||||
? doc?.accesses_versions_per_user?.[user.suite_user_id]
|
||||
: undefined,
|
||||
);
|
||||
const { setCurrentDoc } = useDocStore();
|
||||
const { addTask } = useBroadcastStore();
|
||||
|
||||
+312
-49
@@ -1809,6 +1809,13 @@
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@eslint-community/eslint-utils@^4.8.0":
|
||||
version "4.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
|
||||
integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@eslint-community/eslint-utils@^4.9.1":
|
||||
version "4.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
|
||||
@@ -1816,11 +1823,67 @@
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.2":
|
||||
"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2":
|
||||
version "4.12.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||
|
||||
"@eslint/config-array@^0.21.1":
|
||||
version "0.21.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.2.tgz#f29e22057ad5316cf23836cee9a34c81fffcb7e6"
|
||||
integrity sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==
|
||||
dependencies:
|
||||
"@eslint/object-schema" "^2.1.7"
|
||||
debug "^4.3.1"
|
||||
minimatch "^3.1.5"
|
||||
|
||||
"@eslint/config-helpers@^0.4.2":
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda"
|
||||
integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==
|
||||
dependencies:
|
||||
"@eslint/core" "^0.17.0"
|
||||
|
||||
"@eslint/core@^0.17.0":
|
||||
version "0.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c"
|
||||
integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
"@eslint/eslintrc@^3.3.1":
|
||||
version "3.3.6"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.6.tgz#d22bfd6b3a7d8e1f2c0b2f2e6de111b53ec6e13e"
|
||||
integrity sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==
|
||||
dependencies:
|
||||
ajv "^6.14.0"
|
||||
debug "^4.3.2"
|
||||
espree "^10.0.1"
|
||||
globals "^14.0.0"
|
||||
ignore "^5.2.0"
|
||||
import-fresh "^3.2.1"
|
||||
js-yaml "^4.3.0"
|
||||
minimatch "^3.1.5"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/js@9.39.2":
|
||||
version "9.39.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599"
|
||||
integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==
|
||||
|
||||
"@eslint/object-schema@^2.1.7":
|
||||
version "2.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad"
|
||||
integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==
|
||||
|
||||
"@eslint/plugin-kit@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2"
|
||||
integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==
|
||||
dependencies:
|
||||
"@eslint/core" "^0.17.0"
|
||||
levn "^0.4.1"
|
||||
|
||||
"@exodus/bytes@^1.6.0":
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.8.0.tgz#8382835f71db8377cf634a4ef5a71806e86ba9c7"
|
||||
@@ -2042,6 +2105,37 @@
|
||||
lib0 "^0.2.47"
|
||||
ws "^8.5.0"
|
||||
|
||||
"@humanfs/core@^0.19.2":
|
||||
version "0.19.2"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
|
||||
integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
|
||||
dependencies:
|
||||
"@humanfs/types" "^0.15.0"
|
||||
|
||||
"@humanfs/node@^0.16.6":
|
||||
version "0.16.8"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
|
||||
integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
|
||||
dependencies:
|
||||
"@humanfs/core" "^0.19.2"
|
||||
"@humanfs/types" "^0.15.0"
|
||||
"@humanwhocodes/retry" "^0.4.0"
|
||||
|
||||
"@humanfs/types@^0.15.0":
|
||||
version "0.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
|
||||
integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
|
||||
|
||||
"@humanwhocodes/module-importer@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
|
||||
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
|
||||
|
||||
"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
|
||||
version "0.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
|
||||
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
|
||||
|
||||
"@img/colour@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@img/colour/-/colour-1.0.0.tgz#d2fabb223455a793bf3bf9c70de3d28526aa8311"
|
||||
@@ -6527,7 +6621,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.14.0.tgz#7d9ac55419f353cdd3817b8f5d56a11e909b1251"
|
||||
integrity sha512-zmnWlsi2g/tMlThHby0Je9O+v24j4d+qcXF3nuzLUUaDsGCEtOyC9RzwITft59ViK+Nc2PD2W/J14rsB0j+qoQ==
|
||||
|
||||
"@tiptap/extensions@*", "@tiptap/extensions@^3.13.0":
|
||||
"@tiptap/extensions@*", "@tiptap/extensions@3.14.0", "@tiptap/extensions@^3.13.0":
|
||||
version "3.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.14.0.tgz#8367d3d644cf68b85341e059f5685b13b5722b1a"
|
||||
integrity sha512-qQBVKqzU4ZVjRn8W0UbdfE4LaaIgcIWHOMrNnJ+PutrRzQ6ZzhmD/kRONvRWBfG9z3DU7pSKGwVYSR2hztsGuQ==
|
||||
@@ -6711,6 +6805,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
|
||||
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
|
||||
|
||||
"@types/estree@^1.0.6":
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
|
||||
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
|
||||
|
||||
"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz#74f47555b3d804b54cb7030e6f9aa0c7485cfc5b"
|
||||
@@ -6859,20 +6958,13 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/node@*", "@types/node@>=13.7.0", "@types/node@^24.0.1":
|
||||
"@types/node@*", "@types/node@22.10.7", "@types/node@24.10.9", "@types/node@>=13.7.0", "@types/node@^24.0.1":
|
||||
version "24.10.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.9.tgz#1aeb5142e4a92957489cac12b07f9c7fe26057d0"
|
||||
integrity sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==
|
||||
dependencies:
|
||||
undici-types "~7.16.0"
|
||||
|
||||
"@types/node@22.10.7":
|
||||
version "22.10.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.7.tgz#14a1ca33fd0ebdd9d63593ed8d3fbc882a6d28d7"
|
||||
integrity sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==
|
||||
dependencies:
|
||||
undici-types "~6.20.0"
|
||||
|
||||
"@types/parse-json@^4.0.0":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
|
||||
@@ -6925,7 +7017,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
|
||||
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
|
||||
|
||||
"@types/react-dom@*":
|
||||
"@types/react-dom@*", "@types/react-dom@19.2.3":
|
||||
version "19.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c"
|
||||
integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==
|
||||
@@ -6942,7 +7034,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044"
|
||||
integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==
|
||||
|
||||
"@types/react@*":
|
||||
"@types/react@*", "@types/react@19.2.8":
|
||||
version "19.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.8.tgz#307011c9f5973a6abab8e17d0293f48843627994"
|
||||
integrity sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==
|
||||
@@ -7732,6 +7824,11 @@ acorn-import-phases@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7"
|
||||
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
|
||||
|
||||
acorn-jsx@^5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
acorn-walk@^8.1.1:
|
||||
version "8.3.4"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7"
|
||||
@@ -7770,6 +7867,16 @@ ajv-keywords@^5.1.0:
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.3"
|
||||
|
||||
ajv@^6.12.4, ajv@^6.14.0:
|
||||
version "6.15.0"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
|
||||
integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.1"
|
||||
fast-json-stable-stringify "^2.0.0"
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ajv@^8.0.0, ajv@^8.0.1, ajv@^8.6.0, ajv@^8.9.0:
|
||||
version "8.17.1"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6"
|
||||
@@ -7809,7 +7916,7 @@ ansi-styles@^5.0.0, ansi-styles@^5.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
|
||||
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
|
||||
|
||||
ansi-styles@^6.1.0, ansi-styles@^6.2.1:
|
||||
ansi-styles@^6.2.1:
|
||||
version "6.2.3"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
|
||||
integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
|
||||
@@ -8431,7 +8538,7 @@ chai@^6.2.1:
|
||||
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.1.tgz#d1e64bc42433fbee6175ad5346799682060b5b6a"
|
||||
integrity sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==
|
||||
|
||||
chalk@4.1.2, chalk@^4.1.2:
|
||||
chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
||||
@@ -9002,7 +9109,7 @@ data-view-byte-offset@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
is-data-view "^1.0.1"
|
||||
|
||||
debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3:
|
||||
debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
@@ -9040,6 +9147,11 @@ dedent@^1.6.0:
|
||||
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca"
|
||||
integrity sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==
|
||||
|
||||
deep-is@^0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
||||
|
||||
deepmerge@4.3.1, deepmerge@^4.2.2, deepmerge@^4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
||||
@@ -9753,6 +9865,14 @@ eslint-scope@5.1.1:
|
||||
esrecurse "^4.3.0"
|
||||
estraverse "^4.1.1"
|
||||
|
||||
eslint-scope@^8.4.0:
|
||||
version "8.4.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82"
|
||||
integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==
|
||||
dependencies:
|
||||
esrecurse "^4.3.0"
|
||||
estraverse "^5.2.0"
|
||||
|
||||
eslint-visitor-keys@^3.4.3:
|
||||
version "3.4.3"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
|
||||
@@ -9763,11 +9883,67 @@ eslint-visitor-keys@^4.2.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint@9.39.2:
|
||||
version "9.39.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c"
|
||||
integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.1"
|
||||
"@eslint/config-array" "^0.21.1"
|
||||
"@eslint/config-helpers" "^0.4.2"
|
||||
"@eslint/core" "^0.17.0"
|
||||
"@eslint/eslintrc" "^3.3.1"
|
||||
"@eslint/js" "9.39.2"
|
||||
"@eslint/plugin-kit" "^0.4.1"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
"@humanwhocodes/retry" "^0.4.2"
|
||||
"@types/estree" "^1.0.6"
|
||||
ajv "^6.12.4"
|
||||
chalk "^4.0.0"
|
||||
cross-spawn "^7.0.6"
|
||||
debug "^4.3.2"
|
||||
escape-string-regexp "^4.0.0"
|
||||
eslint-scope "^8.4.0"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
espree "^10.4.0"
|
||||
esquery "^1.5.0"
|
||||
esutils "^2.0.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
file-entry-cache "^8.0.0"
|
||||
find-up "^5.0.0"
|
||||
glob-parent "^6.0.2"
|
||||
ignore "^5.2.0"
|
||||
imurmurhash "^0.1.4"
|
||||
is-glob "^4.0.0"
|
||||
json-stable-stringify-without-jsonify "^1.0.1"
|
||||
lodash.merge "^4.6.2"
|
||||
minimatch "^3.1.2"
|
||||
natural-compare "^1.4.0"
|
||||
optionator "^0.9.3"
|
||||
|
||||
espree@^10.0.1, espree@^10.4.0:
|
||||
version "10.4.0"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837"
|
||||
integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==
|
||||
dependencies:
|
||||
acorn "^8.15.0"
|
||||
acorn-jsx "^5.3.2"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
|
||||
esprima@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
|
||||
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
|
||||
|
||||
esquery@^1.5.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
|
||||
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
|
||||
dependencies:
|
||||
estraverse "^5.1.0"
|
||||
|
||||
esrecurse@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
|
||||
@@ -9780,7 +9956,7 @@ estraverse@^4.1.1:
|
||||
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
|
||||
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
|
||||
|
||||
estraverse@^5.2.0, estraverse@^5.3.0:
|
||||
estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
|
||||
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
|
||||
@@ -9912,7 +10088,7 @@ extend@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
|
||||
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
|
||||
|
||||
fast-deep-equal@^3.1.3:
|
||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
@@ -9954,11 +10130,16 @@ fast-glob@^3.2.9, fast-glob@^3.3.2, fast-glob@^3.3.3:
|
||||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.8"
|
||||
|
||||
fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0:
|
||||
fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
|
||||
fast-levenshtein@^2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
||||
|
||||
fast-safe-stringify@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
|
||||
@@ -10026,6 +10207,13 @@ file-entry-cache@^11.1.1:
|
||||
dependencies:
|
||||
flat-cache "^6.1.19"
|
||||
|
||||
file-entry-cache@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
|
||||
integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
|
||||
dependencies:
|
||||
flat-cache "^4.0.0"
|
||||
|
||||
file-selector@^2.1.0:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4"
|
||||
@@ -10087,6 +10275,14 @@ find-yarn-workspace-root@^2.0.0:
|
||||
dependencies:
|
||||
micromatch "^4.0.2"
|
||||
|
||||
flat-cache@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
|
||||
integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
|
||||
dependencies:
|
||||
flatted "^3.2.9"
|
||||
keyv "^4.5.4"
|
||||
|
||||
flat-cache@^6.1.19:
|
||||
version "6.1.19"
|
||||
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-6.1.19.tgz#20e5b201c9b181a7b773b3b150108932077d2bbf"
|
||||
@@ -10096,6 +10292,11 @@ flat-cache@^6.1.19:
|
||||
flatted "^3.3.3"
|
||||
hookified "^1.13.0"
|
||||
|
||||
flatted@^3.2.9:
|
||||
version "3.4.3"
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.3.tgz#be5c21e943b2d7a328bb23795ae28c98f0103a9e"
|
||||
integrity sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==
|
||||
|
||||
flatted@^3.3.3:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
|
||||
@@ -10451,6 +10652,11 @@ global-prefix@^3.0.0:
|
||||
kind-of "^6.0.2"
|
||||
which "^1.3.1"
|
||||
|
||||
globals@^14.0.0:
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
|
||||
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
|
||||
|
||||
globals@^16.4.0:
|
||||
version "16.5.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-16.5.0.tgz#ccf1594a437b97653b2be13ed4d8f5c9f850cac1"
|
||||
@@ -11212,7 +11418,7 @@ is-generator-function@^1.0.10:
|
||||
has-tostringtag "^1.0.2"
|
||||
safe-regex-test "^1.1.0"
|
||||
|
||||
is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
||||
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
|
||||
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||
@@ -11866,6 +12072,13 @@ js-yaml@^4.1.0:
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
|
||||
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
jsdom@27.4.0:
|
||||
version "27.4.0"
|
||||
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-27.4.0.tgz#c36af2e43e1281a7e8bb8f255086435d177801f2"
|
||||
@@ -11924,11 +12137,21 @@ jsesc@^3.0.2, jsesc@~3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d"
|
||||
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
|
||||
|
||||
json-buffer@3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
|
||||
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
|
||||
|
||||
json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
|
||||
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
|
||||
|
||||
json-schema-traverse@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
||||
|
||||
json-schema-traverse@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
|
||||
@@ -11939,6 +12162,11 @@ json-schema@^0.4.0:
|
||||
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
|
||||
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
|
||||
|
||||
json-stable-stringify@^1.0.2:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70"
|
||||
@@ -12008,6 +12236,13 @@ jszip@^3.10.1:
|
||||
readable-stream "~2.3.6"
|
||||
setimmediate "^1.0.5"
|
||||
|
||||
keyv@^4.5.4:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
|
||||
dependencies:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
keyv@^5.5.4:
|
||||
version "5.5.4"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-5.5.4.tgz#0f26a32183a5058f93fc6e02ced6318f66e8a9ea"
|
||||
@@ -12059,6 +12294,14 @@ leven@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
|
||||
integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
|
||||
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
|
||||
dependencies:
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "~0.4.0"
|
||||
|
||||
lib0@^0.2.102, lib0@^0.2.99:
|
||||
version "0.2.117"
|
||||
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.117.tgz#6c3f926475d28904af05b590703cbbbc29475716"
|
||||
@@ -12837,6 +13080,13 @@ minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^3.1.5:
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
|
||||
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^5.0.1:
|
||||
version "5.1.6"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
|
||||
@@ -13142,6 +13392,18 @@ open@^7.4.2:
|
||||
is-docker "^2.0.0"
|
||||
is-wsl "^2.1.1"
|
||||
|
||||
optionator@^0.9.3:
|
||||
version "0.9.4"
|
||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
|
||||
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
|
||||
dependencies:
|
||||
deep-is "^0.1.3"
|
||||
fast-levenshtein "^2.0.6"
|
||||
levn "^0.4.1"
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "^0.4.0"
|
||||
word-wrap "^1.2.5"
|
||||
|
||||
orderedmap@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
|
||||
@@ -13523,6 +13785,11 @@ preact@^10.28.0:
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.2.tgz#4b668383afa4b4a2546bbe4bd1747e02e2360138"
|
||||
integrity sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==
|
||||
|
||||
prelude-ls@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||
|
||||
prettier-linter-helpers@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
|
||||
@@ -13755,7 +14022,7 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor
|
||||
dependencies:
|
||||
prosemirror-model "^1.21.0"
|
||||
|
||||
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1, prosemirror-view@^1.41.4:
|
||||
prosemirror-view@1.41.4, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1, prosemirror-view@^1.41.4:
|
||||
version "1.41.4"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.4.tgz#4e1b3e90accc0eebe3bddb497a40ce54e4de722d"
|
||||
integrity sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==
|
||||
@@ -14136,7 +14403,7 @@ react-dnd@^14.0.3:
|
||||
fast-deep-equal "^3.1.3"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
|
||||
react-dom@*:
|
||||
react-dom@*, react-dom@19.2.3:
|
||||
version "19.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
|
||||
integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==
|
||||
@@ -14391,7 +14658,7 @@ react-window@^1.8.11:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@*:
|
||||
react@*, react@19.2.3:
|
||||
version "19.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
|
||||
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
|
||||
@@ -15274,7 +15541,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^5.0.1, string-width@^5.1.2:
|
||||
string-width@^5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
|
||||
integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
|
||||
@@ -15966,6 +16233,13 @@ tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
|
||||
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
|
||||
dependencies:
|
||||
prelude-ls "^1.2.1"
|
||||
|
||||
type-detect@4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
|
||||
@@ -16045,7 +16319,7 @@ typed-array-length@^1.0.7:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
reflect.getprototypeof "^1.0.6"
|
||||
|
||||
typescript@*, typescript@^5.0.4:
|
||||
typescript@*, typescript@5.9.3, typescript@^5.0.4:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||
@@ -16083,11 +16357,6 @@ underscore.string@~3.3.4:
|
||||
sprintf-js "^1.1.1"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
undici-types@~6.20.0:
|
||||
version "6.20.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433"
|
||||
integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==
|
||||
|
||||
undici-types@~7.16.0:
|
||||
version "7.16.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
|
||||
@@ -16276,6 +16545,13 @@ update-browserslist-db@^1.2.0:
|
||||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
||||
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
use-callback-ref@^1.3.3:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf"
|
||||
@@ -16773,6 +17049,11 @@ why-is-node-running@^2.3.0:
|
||||
siginfo "^2.0.0"
|
||||
stackback "0.0.2"
|
||||
|
||||
word-wrap@^1.2.5:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||
|
||||
wordwrap@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||
@@ -16956,25 +17237,7 @@ workbox-window@7.1.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
|
||||
dependencies:
|
||||
ansi-styles "^6.1.0"
|
||||
string-width "^5.0.1"
|
||||
strip-ansi "^7.0.1"
|
||||
|
||||
wrap-ansi@^9.0.0:
|
||||
wrap-ansi@9.0.2, wrap-ansi@^7.0.0, wrap-ansi@^8.1.0, wrap-ansi@^9.0.0:
|
||||
version "9.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"
|
||||
integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==
|
||||
@@ -17122,7 +17385,7 @@ yargs@^17.7.2:
|
||||
y18n "^5.0.5"
|
||||
yargs-parser "^21.1.1"
|
||||
|
||||
yjs@*, yjs@^13.6.27:
|
||||
yjs@*, yjs@13.6.29, yjs@^13.6.27:
|
||||
version "13.6.29"
|
||||
resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.29.tgz#bdc3e8379ff36603bcd6d3a1889752e4dd00f637"
|
||||
integrity sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==
|
||||
|
||||
Reference in New Issue
Block a user