diff --git a/package.json b/package.json index 82693fb0548..759b2bf3aed 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@peculiar/x509": "1.9.5", "@wireapp/avs": "9.5.2", "@wireapp/commons": "5.2.3", - "@wireapp/core": "42.21.0", + "@wireapp/core": "42.25.2", "@wireapp/react-ui-kit": "9.12.0", "@wireapp/store-engine-dexie": "2.1.6", "@wireapp/webapp-events": "0.18.3", diff --git a/server/config/client.config.ts b/server/config/client.config.ts index 90c2a882da5..2319b0b14db 100644 --- a/server/config/client.config.ts +++ b/server/config/client.config.ts @@ -99,6 +99,7 @@ export function generateConfig(params: ConfigGeneratorParams, env: Env) { OAUTH_LEARN_MORE: env.URL_SUPPORT_OAUTH_LEARN_MORE, OFFLINE_BACKEND: env.URL_SUPPORT_OFFLINE_BACKEND, FEDERATION_STOP: env.URL_SUPPORT_FEDERATION_STOP, + E2EI_VERIFICATION: env.URL_SUPPORT_E2EI_VERIFICATION, }, TEAMS_BASE: env.URL_TEAMS_BASE, TEAMS_CREATE: env.URL_TEAMS_CREATE, diff --git a/server/config/env.ts b/server/config/env.ts index 484d0e7d06a..7204548716e 100644 --- a/server/config/env.ts +++ b/server/config/env.ts @@ -252,6 +252,7 @@ export type Env = { URL_SUPPORT_OFFLINE_BACKEND: string; URL_SUPPORT_FEDERATION_STOP: string; + URL_SUPPORT_E2EI_VERIFICATION: string; URL_WHATS_NEW: string; diff --git a/src/__mocks__/@wireapp/core-crypto.ts b/src/__mocks__/@wireapp/core-crypto.ts index 0f1756edff7..745f3f5c370 100644 --- a/src/__mocks__/@wireapp/core-crypto.ts +++ b/src/__mocks__/@wireapp/core-crypto.ts @@ -41,3 +41,17 @@ export enum CredentialType { Basic = 1, X509 = 2, } +export enum E2eiConversationState { + /** + * All clients have a valid E2EI certificate + */ + Verified = 1, + /** + * Some clients are either still Basic or their certificate is expired + */ + Degraded = 2, + /** + * All clients are still Basic. If all client have expired certificates, Degraded is returned. + */ + NotEnabled = 3, +} diff --git a/src/__mocks__/@wireapp/core.ts b/src/__mocks__/@wireapp/core.ts index 016a95cda7f..9559b515562 100644 --- a/src/__mocks__/@wireapp/core.ts +++ b/src/__mocks__/@wireapp/core.ts @@ -32,8 +32,17 @@ export class Account extends EventEmitter { }; configureMLSCallbacks = jest.fn(); - + enrollE2EI = jest.fn(); service = { + e2eIdentity: { + isEnrollmentInProgress: jest.fn(), + clearAllProgress: jest.fn(), + hasActiveCertificate: jest.fn(), + getCertificateData: jest.fn(), + getUsersIdentities: jest.fn(() => new Map()), + getDeviceIdentities: jest.fn(), + getConversationState: jest.fn(), + }, mls: { schedulePeriodicKeyMaterialRenewals: jest.fn(), addUsersToExistingConversation: jest.fn(), @@ -45,9 +54,9 @@ export class Account extends EventEmitter { getClientIds: jest.fn(), getEpoch: jest.fn(), exportSecretKey: jest.fn(), - on: this.on, - emit: this.emit, - off: this.off, + on: jest.fn(), + emit: jest.fn(), + off: jest.fn(), scheduleKeyMaterialRenewal: jest.fn(), }, asset: { diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 75e56cdfa2c..8275340a1d7 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -544,7 +544,29 @@ "customEnvRedirect.credentialsInfo": "Provide credentials only if you're sure this is your organization's login.", "customEnvRedirect.redirectHeadline": "Redirecting...", "customEnvRedirect.redirectTo": "You are being redirected to your dedicated enterprise service.", - "downloadLatestMLS": "Download the latest MLS Wire version", + "E2EI.verified": "Verified (End-to-end Identity)", + "E2EI.deviceVerified": "Device verified (End-to-end identity)", + "E2EI.certificateExpired": "End-to-end identity certificate expired", + "E2EI.certificateExpiresSoon": "End-to-end identity certificate expires soon", + "E2EI.certificateRevoked": "End-to-end identity certificate revoked", + "E2EI.status": "Status: ", + "E2EI.certificateTitle": "End-to-end identity certificate", + "E2EI.valid": "Valid", + "E2EI.not_downloaded": "Not downloaded", + "E2EI.expired": "Expired", + "E2EI.expires_soon": "Valid (expires soon)", + "E2EI.not_activated": "Not activated", + "E2EI.serialNumber": "Serial number: ", + "E2EI.notAvailable": "Not available", + "E2EI.showCertificateDetails": "Show Certificate Details", + "E2EI.getCertificate": "Get Certificate", + "E2EI.updateCertificate": "Update Certificate", + "E2EI.certificateDetails": "Certificate details (PEM format)", + "E2EI.downloadCertificate": "Download", + "E2EI.copyCertificate": "Copy to Clipboard", + "E2EI.certificateCopied": "Text copied!", + "proteusVerifiedDetails": "Verified (Proteus)", + "proteusDeviceVerified": "Device verified (Proteus)", "enumerationAnd": ", and ", "ephemeralRemaining": "remaining", "ephemeralUnitsDay": "day", @@ -756,6 +778,12 @@ "mlsConversationRecovered": "You haven't used this device for a while, or an issue has occurred. Some older messages may not appear here.", "mlsToggleInfo": "When this is on, conversation will use the new messaging layer security (MLS) protocol.", "mlsToggleName": "MLS", + "mlsSignature": "MLS with {{signature}} Signature", + "mlsThumbprint": "MLS Thumbprint", + "selfNotSupportMLSMsgPart1": "You can't communicate with [bold]{{selfUserName}}[/bold] anymore, as your device doesn't support the suitable protocol.", + "downloadLatestMLS": "Download the latest MLS Wire version", + "selfNotSupportMLSMsgPart2": "to call, and send messages and files again.", + "otherUserNotSupportMLSMsg": "You can't communicate with [bold]{{participantName}}[/bold] anymore, as you two now use different protocols. When [bold]{{participantName}}[/bold] gets an update, you can call and send messages and files again.", "modalAccountCreateAction": "OK", "modalAccountCreateHeadline": "Create an account?", "modalAccountCreateMessage": "By creating an account you will lose the conversation history in this guest room.", @@ -1050,12 +1078,15 @@ "ongoingGroupAudioCall": "Ongoing conference call with {{conversationName}}.", "ongoingGroupVideoCall": "Ongoing video conference call with {{conversationName}}, your camera is {{cameraStatus}}.", "ongoingVideoCall": "Ongoing video call with {{conversationName}}, your camera is {{cameraStatus}}.", - "otherUserNotSupportMLSMsg": "You can't communicate with [bold]{{participantName}}[/bold] anymore, as you two now use different protocols. When [bold]{{participantName}}[/bold] gets an update, you can call and send messages and files again.", + "participantDevicesProteusDeviceVerification": "Proteus Device Verification", + "participantDevicesProteusKeyFingerprint": "Proteus Key Fingerprint", "participantDevicesDetailHeadline": "Verify that this matches the fingerprint shown on [bold]{{user}}’s device[/bold].", "participantDevicesDetailHowTo": "How do I do that?", "participantDevicesDetailResetSession": "Reset session", "participantDevicesDetailShowMyDevice": "Show my device fingerprint", + "preferencesDeviceDetailsVerificationStatus": "Verification Status", "participantDevicesDetailVerify": "Verified", + "preferencesDeviceDetailsFingerprintNotMatch": "If fingerprints don’t match, reset the session to generate new encryption keys on both sides.", "participantDevicesHeader": "Devices", "participantDevicesHeadline": "{{brandName}} gives every device a unique fingerprint. Compare them with {{user}} and verify your conversation.", "participantDevicesLearnMore": "Learn more", @@ -1134,13 +1165,14 @@ "preferencesDeviceDetails": "Device Details", "preferencesDeviceNotVerified": "not verified", "preferencesDevices": "Devices", - "preferencesDevicesActivatedOn": "Activated [bold]{{date}}[/bold]", + "preferencesDevicesActivatedOn": "Activated", "preferencesDevicesActive": "Active", "preferencesDevicesActiveDetail": "If you don’t recognize a device above, remove it and reset your password.", "preferencesDevicesCurrent": "Current", "preferencesDevicesFingerprint": "Key fingerprint", "preferencesDevicesFingerprintDetail": "{{brandName}} gives every device a unique fingerprint. Compare them and verify your devices and conversations.", "preferencesDevicesId": "ID: ", + "preferencesMLSThumbprint": "MLS Thumbprint: ", "preferencesDevicesRemove": "Remove Device", "preferencesDevicesRemoveCancel": "Cancel", "preferencesDevicesRemoveDetail": "Remove this device if you have stopped using it. You will be logged out of this device immediately.", @@ -1194,6 +1226,10 @@ "preferencesOptionsPreviewsSendCheckbox": "Create previews for links you send", "preferencesOptionsPreviewsSendDetail": "Previews may still be shown for links from other people.", "preferencesOptionsUseDarkMode": "Dark theme", + "proteusDeviceDetails": "Proteus Device Details", + "proteusID": "Proteus ID", + "proteusVerified": "Verified", + "proteusNotVerified": "Not Verified", "readReceiptsToggleInfo": "When this is on, people can see when their messages in this conversation are read.", "readReceiptsToggleName": "Read receipts", "receiptToggleInfo": "When this is on, people can see when their messages in this conversation are read.", @@ -1250,8 +1286,6 @@ "searchTrySearch": "Find people by\nname or username", "searchTrySearchFederation": "Find people in Wire by name or\n@username\n\nFind people from another domain\nby @username@domainname", "searchTrySearchLearnMore": "Learn more", - "selfNotSupportMLSMsgPart1": "You can't communicate with [bold]{{selfUserName}}[/bold] anymore, as your device doesn't support the suitable protocol.", - "selfNotSupportMLSMsgPart2": "to call, and send messages and files again.", "selfProfileImageAlt": "Your profile picture", "servicesOptionsTitle": "Services", "servicesRoomToggleInfo": "Open this conversation to services.", @@ -1302,7 +1336,10 @@ "timedMessageDisclaimer": "Self-deleting messages will be turned on for all the participants in this conversation.", "timedMessagesTitle": "Self-deleting messages", "tooltipConversationAddImage": "Add picture", - "tooltipConversationAllVerified": "All fingerprints are verified", + "tooltipConversationAllVerified": "All fingerprints are verified (Proteus)", + "tooltipConversationAllDevicesVerified": "All device fingerprints verified (Proteus)", + "tooltipConversationAllE2EIVerified": "All devices are verified (end-to-end identity). [link]Learn more[/link]", + "tooltipConversationAllE2EIVerifiedShort": "All devices verified (end-to-end identity)", "tooltipConversationCall": "Call", "tooltipConversationDetailsAddPeople": "Add participants to conversation ({{shortcut}})", "tooltipConversationDetailsRename": "Change conversation name", diff --git a/src/script/E2EIdentity/E2EIdentity.test.ts b/src/script/E2EIdentity/E2EIdentityEnrollment.test.ts similarity index 61% rename from src/script/E2EIdentity/E2EIdentity.test.ts rename to src/script/E2EIdentity/E2EIdentityEnrollment.test.ts index d8f0853d1e2..ad79ba6407b 100644 --- a/src/script/E2EIdentity/E2EIdentity.test.ts +++ b/src/script/E2EIdentity/E2EIdentityEnrollment.test.ts @@ -26,7 +26,7 @@ import {Core} from 'src/script/service/CoreSingleton'; import {UserState} from 'src/script/user/UserState'; import * as util from 'Util/util'; -import {E2EIHandler, E2EIHandlerStep} from './E2EIdentity'; +import {E2EIHandler, E2EIHandlerStep} from './E2EIdentityEnrollment'; import {getModalOptions, ModalType} from './Modals'; import {OIDCService} from './OIDCService/OIDCService'; @@ -35,24 +35,7 @@ jest.mock('./OIDCService', () => ({ clearProgress: jest.fn(), } as unknown as OIDCService), })); -jest.mock('Util/util'); -jest.mock('src/script/Config'); -jest.mock('tsyringe'); -jest.mock('src/script/service/CoreSingleton', () => { - return { - Core: jest.fn().mockImplementation(() => { - return {enrollE2EI: jest.fn()}; - }), - }; -}); -jest.mock('src/script/user/UserState', () => { - return { - UserState: jest.fn().mockImplementation(() => { - return {self: jest.fn()}; - }), - }; -}); -jest.mock('Components/Modals/PrimaryModal'); + jest.mock('./Modals', () => ({ getModalOptions: jest.fn().mockReturnValue({ modalOptions: {}, @@ -65,53 +48,30 @@ jest.mock('./Modals', () => ({ ENROLL: 'enroll', }, })); -jest.mock('src/script/service/CoreSingleton', () => ({ - Core: jest.fn().mockImplementation(() => ({ - enrollE2EI: jest.fn(), - })), -})); -jest.mock('src/script/user/UserState', () => ({ - UserState: jest.fn().mockImplementation(() => ({ - self: jest.fn(), - })), -})); describe('E2EIHandler', () => { const params = {discoveryUrl: 'http://example.com', gracePeriodInSeconds: 30}; const newParams = {discoveryUrl: 'http://new-example.com', gracePeriodInSeconds: 60}; const user = {name: () => 'John Doe', username: () => 'johndoe'}; - let coreMock: Core; - let userStateMock: UserState; beforeEach(() => { - (util.supportsMLS as jest.Mock).mockReturnValue(true); + jest.spyOn(util, 'supportsMLS').mockReturnValue(true); // Reset the singleton instance before each test E2EIHandler.resetInstance(); // Clear all mocks before each test jest.clearAllMocks(); - // Setup the Core and UserState services mocks - coreMock = new Core(); - userStateMock = new UserState(); - (userStateMock.self as unknown as jest.Mock).mockReturnValue({name: () => 'John Doe', username: () => 'johndoe'}); - (coreMock.enrollE2EI as jest.Mock).mockResolvedValue(true); - - (container.resolve as jest.Mock).mockImplementation(service => { - if (service === Core) { - return coreMock; - } - if (service === UserState) { - return userStateMock; - } - return null; - }); // Mock the Config service to return true for ENABLE_E2EI (util.supportsMLS as jest.Mock).mockReturnValue(true); Config.getConfig = jest.fn().mockReturnValue({FEATURE: {ENABLE_E2EI: true}}); - // Mock the PrimaryModal service to return a mock modal - (PrimaryModal.show as jest.Mock).mockClear(); + jest.spyOn(PrimaryModal, 'show'); (getModalOptions as jest.Mock).mockClear(); + + jest + .spyOn(container.resolve(UserState), 'self') + .mockReturnValue({name: () => 'John Doe', username: () => 'johndoe'}); + jest.spyOn(container.resolve(Core), 'enrollE2EI').mockResolvedValue(true); }); it('should create instance with valid params', () => { @@ -143,48 +103,32 @@ describe('E2EIHandler', () => { expect(instance['gracePeriodInMS']).toEqual(newParams.gracePeriodInSeconds * TimeInMillis.SECOND); }); - it('should return true when supportsMLS returns true and ENABLE_E2EI is true', () => { - const instance = E2EIHandler.getInstance(params); - expect(instance.isE2EIEnabled).toBe(true); - }); - - it('should return false when supportsMLS returns false', () => { - (util.supportsMLS as jest.Mock).mockReturnValue(false); - - const instance = E2EIHandler.getInstance(params); - expect(instance.isE2EIEnabled).toBe(false); - }); - - it('should return false when ENABLE_E2EI is false', () => { - Config.getConfig = jest.fn().mockReturnValue({FEATURE: {ENABLE_E2EI: false}}); - - const instance = E2EIHandler.getInstance(params); - expect(instance.isE2EIEnabled).toBe(false); - }); - it('should set currentStep to INITIALIZE after initialize is called', () => { const instance = E2EIHandler.getInstance(params); instance.initialize(); expect(instance['currentStep']).toBe(E2EIHandlerStep.INITIALIZED); }); - it('should set currentStep to ENROLL when enrollE2EI is called and enrollment succeeds', async () => { + it('should set currentStep to SUCCESS when enrollE2EI is called and enrollment succeeds', async () => { + jest + .spyOn(container.resolve(UserState), 'self') + .mockReturnValue({name: () => 'John Doe', username: () => 'johndoe'}); + + jest.spyOn(container.resolve(Core), 'enrollE2EI').mockResolvedValueOnce(true); + const instance = E2EIHandler.getInstance(params); - await instance['enrollE2EI'](); + await instance['enroll'](); + expect(instance['currentStep']).toBe(E2EIHandlerStep.SUCCESS); }); - it('should set currentStep to ERROR when enrollE2EI is called and enrollment fails', async () => { + it('should set currentStep to ERROR when enrolE2EI is called and enrolment fails', async () => { // Mock the Core service to return an error - (container.resolve as any) = jest.fn(service => { - if (service === Core) { - return {enrollE2EI: jest.fn(() => Promise.reject())}; - } - return {self: () => user}; - }); + jest.spyOn(container.resolve(Core), 'enrollE2EI').mockImplementationOnce(jest.fn(() => Promise.reject())); + jest.spyOn(container.resolve(UserState), 'self').mockImplementationOnce(() => user); const instance = E2EIHandler.getInstance(params); - await instance['enrollE2EI'](); + await instance['enroll'](); expect(instance['currentStep']).toBe(E2EIHandlerStep.ERROR); }); @@ -198,9 +142,9 @@ describe('E2EIHandler', () => { ); }); - it('should display loading message when enrolled', async () => { + it('should display loading message when enroled', async () => { const handler = E2EIHandler.getInstance(params); - await handler['enrollE2EI'](); + await handler['enroll'](); expect(getModalOptions).toHaveBeenCalledWith( expect.objectContaining({ type: ModalType.LOADING, @@ -209,9 +153,11 @@ describe('E2EIHandler', () => { }); it('should display success message when enrollment is done', async () => { + jest.spyOn(container.resolve(Core), 'enrollE2EI').mockResolvedValueOnce(true); + const handler = E2EIHandler.getInstance(params); handler['showLoadingMessage'] = jest.fn(); - await handler['enrollE2EI'](); + await handler['enroll'](); expect(getModalOptions).toHaveBeenCalledWith( expect.objectContaining({ type: ModalType.SUCCESS, @@ -220,18 +166,11 @@ describe('E2EIHandler', () => { }); it('should display error message when enrollment fails', async () => { - (container.resolve as jest.Mock).mockImplementation(service => { - if (service === Core) { - return {startE2EIEnrollment: jest.fn(() => Promise.reject(false))}; - } - if (service === UserState) { - return userStateMock; - } - return null; - }); + jest.spyOn(container.resolve(Core), 'enrollE2EI').mockRejectedValueOnce(false); + const handler = E2EIHandler.getInstance(params); handler['showLoadingMessage'] = jest.fn(); - await handler['enrollE2EI'](); + await handler['enroll'](); expect(getModalOptions).toHaveBeenCalledWith( expect.objectContaining({ type: ModalType.ERROR, diff --git a/src/script/E2EIdentity/E2EIdentity.ts b/src/script/E2EIdentity/E2EIdentityEnrollment.ts similarity index 82% rename from src/script/E2EIdentity/E2EIdentity.ts rename to src/script/E2EIdentity/E2EIdentityEnrollment.ts index 34e1fa08a67..24921a2862d 100644 --- a/src/script/E2EIdentity/E2EIdentity.ts +++ b/src/script/E2EIdentity/E2EIdentityEnrollment.ts @@ -20,14 +20,13 @@ import {container} from 'tsyringe'; import {PrimaryModal, removeCurrentModal} from 'Components/Modals/PrimaryModal'; -import {Config} from 'src/script/Config'; import {Core} from 'src/script/service/CoreSingleton'; import {UserState} from 'src/script/user/UserState'; import {TIME_IN_MILLIS} from 'Util/TimeUtil'; import {removeUrlParameters} from 'Util/UrlUtil'; -import {supportsMLS} from 'Util/util'; import {DelayTimerService} from './DelayTimer/DelayTimer'; +import {hasActiveCertificate, isE2EIEnabled} from './E2EIdentityVerification'; import {getModalOptions, ModalType} from './Modals'; import {getOIDCServiceInstance} from './OIDCService'; import {OIDCServiceStore} from './OIDCService/OIDCServiceStorage'; @@ -46,7 +45,7 @@ interface E2EIHandlerParams { gracePeriodInSeconds: number; } -class E2EIHandler { +export class E2EIHandler { private static instance: E2EIHandler | null = null; private readonly core = container.resolve(Core); private readonly userState = container.resolve(UserState); @@ -66,6 +65,16 @@ class E2EIHandler { }); } + private get coreE2EIService() { + const e2eiService = this.core.service?.e2eIdentity; + + if (!e2eiService) { + throw new Error('E2EI Service not available'); + } + + return e2eiService; + } + /** * Get the singleton instance of GracePeriodTimer or create a new one * For the first time, params are required to create the instance @@ -105,17 +114,13 @@ class E2EIHandler { } public initialize(): void { - if (this.isE2EIEnabled) { - if (!this.core.service?.e2eIdentity?.hasActiveCertificate()) { + if (isE2EIEnabled()) { + if (!hasActiveCertificate()) { this.showE2EINotificationMessage(); } } } - get isE2EIEnabled(): boolean { - return supportsMLS() && Config.getConfig().FEATURE.ENABLE_E2EI; - } - private async storeRedirectTargetAndRedirect(targetURL: string): Promise { // store the target url in the persistent oidc service store, since the oidc service will be destroyed after the redirect OIDCServiceStore.store.targetURL(targetURL); @@ -123,15 +128,15 @@ class E2EIHandler { await oidcService.authenticate(); } - private async enrollE2EI() { + public async enroll(refreshActiveCertificate: boolean = false) { try { - // Notify user about E2EI enrollment in progress + // Notify user about E2EI enrolment in progress this.currentStep = E2EIHandlerStep.ENROLL; this.showLoadingMessage(); let oAuthIdToken: string | undefined; - // If the enrollment is in progress, we need to get the id token from the oidc service, since oauth should have already been completed - if (this.core.service?.e2eIdentity?.isEnrollmentInProgress()) { + // If the enrolment is in progress, we need to get the id token from the oidc service, since oauth should have already been completed + if (this.coreE2EIService.isEnrollmentInProgress()) { const oidcService = getOIDCServiceInstance(); const userData = await oidcService.handleAuthentication(); if (!userData) { @@ -140,16 +145,23 @@ class E2EIHandler { oAuthIdToken = userData?.id_token; } - const data = await this.core.enrollE2EI( - this.userState.self().name(), - this.userState.self().username(), - this.discoveryUrl, + const displayName = this.userState.self()?.name(); + const handle = this.userState.self()?.username(); + // If the user has no username or handle, we cannot enroll + if (!displayName || !handle) { + throw new Error('Username or handle not found'); + } + const data = await this.core.enrollE2EI({ + discoveryUrl: this.discoveryUrl, + displayName, + handle, oAuthIdToken, - ); + refreshActiveCertificate, + }); - // If the data is false or we dont get the ACMEChallenge, enrollment failed + // If the data is false or we dont get the ACMEChallenge, enrolment failed if (!data) { - throw new Error('E2EI enrollment failed'); + throw new Error('E2EI enrolment failed'); } // Check if the data is a boolean, if not, we need to handle the oauth redirect @@ -157,7 +169,7 @@ class E2EIHandler { await this.storeRedirectTargetAndRedirect(data.target); } - // Notify user about E2EI enrollment success + // Notify user about E2EI enrolment success // This setTimeout is needed because there was a timing with the success modal and the loading modal setTimeout(() => { removeCurrentModal(); @@ -165,7 +177,7 @@ class E2EIHandler { this.currentStep = E2EIHandlerStep.SUCCESS; this.showSuccessMessage(); - // Remove the url parameters after enrollment + // Remove the url parameters after enrolment removeUrlParameters(); } catch (error) { this.currentStep = E2EIHandlerStep.ERROR; @@ -206,20 +218,20 @@ class E2EIHandler { return; } - // Remove the url parameters of the failed enrollment + // Remove the url parameters of the failed enrolment removeUrlParameters(); // Clear the oidc service progress const oidcService = getOIDCServiceInstance(); await oidcService.clearProgress(); // Clear the e2e identity progress - this.core.service?.e2eIdentity?.clearAllProgress(); + this.coreE2EIService.clearAllProgress(); const {modalOptions, modalType} = getModalOptions({ type: ModalType.ERROR, hideClose: true, primaryActionFn: () => { this.currentStep = E2EIHandlerStep.INITIALIZED; - void this.enrollE2EI(); + void this.enroll(); }, secondaryActionFn: () => { this.showE2EINotificationMessage(); @@ -230,10 +242,10 @@ class E2EIHandler { } private showE2EINotificationMessage(): void { - // If the user has already started enrollment, don't show the notification. Instead, show the loading modal + // If the user has already started enrolment, don't show the notification. Instead, show the loading modal // This will occur after the redirect from the oauth provider - if (this.core.service?.e2eIdentity?.isEnrollmentInProgress()) { - void this.enrollE2EI(); + if (this.coreE2EIService.isEnrollmentInProgress()) { + void this.enroll(); return; } @@ -260,7 +272,7 @@ class E2EIHandler { if (!this.timer.isDelayTimerActive()) { const {modalOptions, modalType} = getModalOptions({ hideSecondary: !this.timer.isSnoozeTimeAvailable(), - primaryActionFn: () => this.enrollE2EI(), + primaryActionFn: () => this.enroll(), secondaryActionFn: () => { this.currentStep = E2EIHandlerStep.SNOOZE; this.timer.delayPrompt(); @@ -272,5 +284,3 @@ class E2EIHandler { } } } - -export {E2EIHandler}; diff --git a/src/script/E2EIdentity/E2EIdentityVerification.ts b/src/script/E2EIdentity/E2EIdentityVerification.ts new file mode 100644 index 00000000000..ef1c4350903 --- /dev/null +++ b/src/script/E2EIdentity/E2EIdentityVerification.ts @@ -0,0 +1,78 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {QualifiedId} from '@wireapp/api-client/lib/user'; +import {DeviceIdentity} from '@wireapp/core/lib/messagingProtocols/mls'; +import {container} from 'tsyringe'; + +import {Core} from 'src/script/service/CoreSingleton'; +import {base64ToArray, supportsMLS} from 'Util/util'; + +import {mapMLSStatus} from './certificateDetails'; + +import {Config} from '../Config'; + +export enum MLSStatuses { + VALID = 'valid', + NOT_DOWNLOADED = 'not_downloaded', + EXPIRED = 'expired', + EXPIRES_SOON = 'expires_soon', +} + +export type WireIdentity = Omit & { + status: MLSStatuses; +}; + +export function getE2EIdentityService() { + const e2eIdentityService = container.resolve(Core).service?.e2eIdentity; + if (!e2eIdentityService) { + throw new Error('trying to query E2EIdentity data in an non-e2eidentity environment'); + } + return e2eIdentityService; +} + +export function isE2EIEnabled(): boolean { + return supportsMLS() && Config.getConfig().FEATURE.ENABLE_E2EI; +} + +export async function getUsersIdentities(groupId: string, userIds: QualifiedId[]) { + const userVerifications = await getE2EIdentityService().getUsersIdentities(groupId, userIds); + + const mappedUsers = new Map(); + + for (const [userId, identities] of userVerifications.entries()) { + mappedUsers.set( + userId, + identities.map(identity => ({...identity, status: mapMLSStatus(identity.status)})), + ); + } + + return mappedUsers; +} + +export async function getConversationVerificationState(groupId: string) { + return getE2EIdentityService().getConversationState(base64ToArray(groupId)); +} + +/** + * Checks if E2EI has active certificate. + */ +export function hasActiveCertificate() { + return getE2EIdentityService().hasActiveCertificate(); +} diff --git a/src/script/page/components/FeatureConfigChange/guards.ts b/src/script/E2EIdentity/certificateDetails.ts similarity index 52% rename from src/script/page/components/FeatureConfigChange/guards.ts rename to src/script/E2EIdentity/certificateDetails.ts index d07f2104988..91496ea9be4 100644 --- a/src/script/page/components/FeatureConfigChange/guards.ts +++ b/src/script/E2EIdentity/certificateDetails.ts @@ -17,14 +17,21 @@ * */ -import {FeatureMLS, FeatureMLSE2EId} from '@wireapp/api-client/lib/team'; +import {WireIdentity} from '@wireapp/core/lib/messagingProtocols/mls'; -const isObject = (value: unknown): value is {} => typeof value === 'object' && value !== null; -const isFeatureWithConfig = (feature: unknown): feature is {config: {}} => - isObject(feature) && 'config' in feature && isObject(feature.config); +import {MLSStatuses} from './E2EIdentityVerification'; -export const hasE2EIVerificationExpiration = (feature: unknown): feature is FeatureMLSE2EId => - isFeatureWithConfig(feature) && 'verificationExpiration' in feature.config; +type CoreStatus = WireIdentity['status']; -export const hasMLSDefaultProtocol = (feature: unknown): feature is FeatureMLS => - isFeatureWithConfig(feature) && 'defaultProtocol' in feature.config; +export const mapMLSStatus = (status?: CoreStatus) => { + const statusMap: Record = { + Valid: MLSStatuses.VALID, + Expired: MLSStatuses.EXPIRED, + Revoked: MLSStatuses.EXPIRED, + }; + + if (!status) { + return MLSStatuses.NOT_DOWNLOADED; + } + return statusMap[status]; +}; diff --git a/src/script/E2EIdentity/index.ts b/src/script/E2EIdentity/index.ts index 6092e227756..ed5294a4610 100644 --- a/src/script/E2EIdentity/index.ts +++ b/src/script/E2EIdentity/index.ts @@ -17,4 +17,5 @@ * */ -export * from './E2EIdentity'; +export * from './E2EIdentityEnrollment'; +export * from './E2EIdentityVerification'; diff --git a/src/script/client/ClientEntity.ts b/src/script/client/ClientEntity.ts index 666284d93dc..d3f61569510 100644 --- a/src/script/client/ClientEntity.ts +++ b/src/script/client/ClientEntity.ts @@ -26,6 +26,10 @@ import {ClientMapper} from './ClientMapper'; import {ClientRecord} from '../storage'; +export enum MLSPublicKeys { + ED25519 = 'ed25519', +} + export class ClientEntity { static CONFIG = { DEFAULT_VALUE: '?', @@ -40,14 +44,14 @@ export class ClientEntity { label?: string; meta: { - isVerified?: ko.Observable; - isMLSVerified?: ko.Observable; + isVerified: ko.Observable; primaryKey?: string; userId?: string; }; model?: string; time?: string; type?: ClientType.PERMANENT | ClientType.TEMPORARY; + mlsPublicKeys?: Partial>; constructor(isSelfClient: boolean, domain: string | null, id = '') { this.isSelfClient = isSelfClient; @@ -68,7 +72,6 @@ export class ClientEntity { // Metadata maintained by us this.meta = { isVerified: ko.observable(false), - isMLSVerified: ko.observable(false), primaryKey: undefined, }; } diff --git a/src/script/client/ClientMapper.test.ts b/src/script/client/ClientMapper.test.ts index f98ebee2fc4..a95655d2417 100644 --- a/src/script/client/ClientMapper.test.ts +++ b/src/script/client/ClientMapper.test.ts @@ -56,7 +56,6 @@ describe('ClientMapper', () => { expect(clientEntity.id).toBe(clientPayload.id); expect(clientEntity.label).toBe(clientPayload.label); expect(clientEntity.meta.isVerified?.()).toBe(false); - expect(clientEntity.meta.isMLSVerified?.()).toBe(false); expect(clientEntity.model).toBe(clientPayload.model); expect(clientEntity.time).toBe(clientPayload.time); expect(clientEntity.type).toBe(ClientType.TEMPORARY); @@ -72,7 +71,6 @@ describe('ClientMapper', () => { expect(clientEntity.id).toBe(clientPayload.id); expect(clientEntity.class).toBe(clientPayload.class); expect(clientEntity.meta.isVerified?.()).toBe(false); - expect(clientEntity.meta.isMLSVerified?.()).toBe(false); expect(clientEntity.isPermanent()).toBe(false); expect(clientEntity.isTemporary()).toBe(false); }); @@ -83,7 +81,6 @@ describe('ClientMapper', () => { id: '66d0515a23a0ef25', meta: { is_verified: true, - is_mls_verified: true, }, }; @@ -103,7 +100,6 @@ describe('ClientMapper', () => { domain: '', meta: { is_verified: true, - is_mls_verified: true, }, }; @@ -141,7 +137,6 @@ describe('ClientMapper', () => { expect(clientEntity.id).toBe(clientPayload.id); expect(clientEntity.label).toBe(clientPayload.label); expect(clientEntity.meta.isVerified?.()).toBe(false); - expect(clientEntity.meta.isMLSVerified?.()).toBe(false); expect(clientEntity.model).toBe(clientPayload.model); expect(clientEntity.time).toBe(clientPayload.time); expect(clientEntity.type).toBe(ClientType.PERMANENT); @@ -162,7 +157,6 @@ describe('ClientMapper', () => { expect(clientEntity.id).toBe(clientPayload.id); expect(clientEntity.label).toBe(clientPayload.label); expect(clientEntity.meta.isVerified?.()).toBe(false); - expect(clientEntity.meta.isMLSVerified?.()).toBe(false); expect(clientEntity.model).toBe(clientPayload.model); expect(clientEntity.time).toBe(clientPayload.time); expect(clientEntity.type).toBe(ClientType.PERMANENT); diff --git a/src/script/client/ClientMapper.ts b/src/script/client/ClientMapper.ts index 8e22617a296..54bcb81edff 100644 --- a/src/script/client/ClientMapper.ts +++ b/src/script/client/ClientMapper.ts @@ -23,7 +23,7 @@ import {ClientEntity} from './ClientEntity'; import {parseClientId} from './ClientIdUtil'; import {ClientRecord} from '../storage'; -import {isClientRecord} from '../util/TypePredicateUtil'; +import {isClientRecord, isClientWithMLSPublicKeys} from '../util/TypePredicateUtil'; export class ClientMapper { static get CONFIG() { @@ -58,11 +58,14 @@ export class ClientMapper { const {userId} = parseClientId(clientPayload.meta.primary_key); clientEntity.meta.isVerified?.(!!clientPayload.meta.is_verified); - clientEntity.meta.isMLSVerified?.(!!clientPayload.meta.is_mls_verified); clientEntity.meta.primaryKey = clientPayload.meta.primary_key; clientEntity.meta.userId = userId; } + if (isClientWithMLSPublicKeys(clientPayload)) { + clientEntity.mlsPublicKeys = clientPayload.mls_public_keys; + } + return clientEntity; } diff --git a/src/script/components/AvailabilityState.test.tsx b/src/script/components/AvailabilityState.test.tsx index 73b420b5ce2..d9dff16020f 100644 --- a/src/script/components/AvailabilityState.test.tsx +++ b/src/script/components/AvailabilityState.test.tsx @@ -23,9 +23,13 @@ import {Availability} from '@wireapp/protocol-messaging'; import {AvailabilityState} from './AvailabilityState'; +import {User} from '../entity/User'; + +const user = new User(); + const defaultProps = { - availability: Availability.Type.AVAILABLE, dataUieName: 'example-data-uie', + user, label: 'example', showArrow: false, theme: false, @@ -33,6 +37,7 @@ const defaultProps = { describe('AvailabilityState', () => { it('renders available icon', async () => { + user.availability(Availability.Type.AVAILABLE); const {getByTestId} = render(); const statusAvailabilityIcon = getByTestId('status-availability-icon'); @@ -40,33 +45,27 @@ describe('AvailabilityState', () => { }); it('renders away icon', async () => { - const props = { - ...defaultProps, - availability: Availability.Type.AWAY, - }; + user.availability(Availability.Type.AWAY); - const {getByTestId} = render(); + const {getByTestId} = render(); const statusAvailabilityIcon = getByTestId('status-availability-icon'); expect(statusAvailabilityIcon.getAttribute('data-uie-value')).toEqual('away'); }); it('renders busy icon', async () => { - const props = { - ...defaultProps, - availability: Availability.Type.BUSY, - }; + user.availability(Availability.Type.BUSY); - const {getByTestId} = render(); + const {getByTestId} = render(); const statusAvailabilityIcon = getByTestId('status-availability-icon'); expect(statusAvailabilityIcon.getAttribute('data-uie-value')).toEqual('busy'); }); it('renders availability icon with arrow', async () => { + user.availability(Availability.Type.BUSY); const props = { ...defaultProps, - availability: Availability.Type.BUSY, showArrow: true, }; @@ -77,18 +76,4 @@ describe('AvailabilityState', () => { expect(getByTestId('availability-arrow')).not.toBeNull(); }); - - it('shows label text', async () => { - const label = 'cool label'; - const props = { - ...defaultProps, - availability: Availability.Type.BUSY, - label, - showArrow: true, - }; - - const {getByText} = render(); - - expect(getByText(label)).not.toBeNull(); - }); }); diff --git a/src/script/components/AvailabilityState.tsx b/src/script/components/AvailabilityState.tsx index 03c0e6c8213..c0a8cf7d1fb 100644 --- a/src/script/components/AvailabilityState.tsx +++ b/src/script/components/AvailabilityState.tsx @@ -24,20 +24,25 @@ import cx from 'classnames'; import {Availability} from '@wireapp/protocol-messaging'; +import {selfIndicator} from 'Components/ParticipantItemContent/ParticipantItem.styles'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {CSS_SQUARE} from 'Util/CSSMixin'; import {KEY} from 'Util/KeyboardUtil'; import {Icon} from './Icon'; +import {User} from '../entity/User'; + interface AvailabilityStateProps { - availability: Availability.Type; + user: User; className?: string; dataUieName: string; - label: string; + selfString?: string; title?: string; onClick?: (event: React.MouseEvent | React.KeyboardEvent) => void; showArrow?: boolean; theme?: boolean; + children?: React.ReactNode; } const iconStyles: CSSObject = { @@ -55,15 +60,18 @@ const buttonCommonStyles: CSSObject = { }; export const AvailabilityState: React.FC = ({ - availability, + user, className, dataUieName, - label, + selfString, title, showArrow = false, theme = false, onClick, + children, }) => { + const {availability, name: label} = useKoSubscribableChildren(user, ['availability', 'name']); + const isAvailable = availability === Availability.Type.AVAILABLE; const isAway = availability === Availability.Type.AWAY; const isBusy = availability === Availability.Type.BUSY; @@ -125,6 +133,10 @@ export const AvailabilityState: React.FC = ({ )} + {selfString && {selfString}} + + {children} + {showArrow && ( { - it('can print device id', () => { - const {getAllByTestId} = render(); - - const deviceIdParts = getAllByTestId('element-device-id-part').map(node => node.textContent); - - expect(deviceIdParts).toEqual(['66', 'e6', '6c', '79', 'e8', 'd1', 'de', 'a4']); - }); - - it('can print device id and apply padding', () => { - const {getAllByTestId} = render(); - - const deviceIdParts = getAllByTestId('element-device-id-part').map(node => node.textContent); - - expect(deviceIdParts).toEqual(['06', 'e6', '6c', '79', 'e8', 'd1', 'de', 'a4']); - }); -}); diff --git a/src/script/components/MessagesList/Message/DecryptErrorMessage.tsx b/src/script/components/MessagesList/Message/DecryptErrorMessage.tsx index 0ea2ae94938..16a1aa3aab9 100644 --- a/src/script/components/MessagesList/Message/DecryptErrorMessage.tsx +++ b/src/script/components/MessagesList/Message/DecryptErrorMessage.tsx @@ -19,13 +19,14 @@ import React, {useState} from 'react'; -import {DeviceId} from 'Components/DeviceId'; import {Icon} from 'Components/Icon'; import {getDecryptErrorUrl} from 'src/script/externalRoute'; import {MotionDuration} from 'src/script/motion/MotionDuration'; import {t} from 'Util/LocalizerUtil'; +import {splitFingerprint} from 'Util/StringUtil'; import {DecryptErrorMessage as DecryptErrorMessageEntity} from '../../../entity/message/DecryptErrorMessage'; +import {FormattedId} from '../../../page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId'; export interface DecryptErrorMessageProps { message: DecryptErrorMessageEntity; @@ -81,7 +82,7 @@ const DecryptErrorMessage: React.FC = ({message, onCli {message.clientId && ( <> {'ID: '} - + )}

diff --git a/src/script/components/MessagesList/Message/SystemMessage/SystemMessage.tsx b/src/script/components/MessagesList/Message/SystemMessage/SystemMessage.tsx index 6b842a1dc7e..565f25ba473 100644 --- a/src/script/components/MessagesList/Message/SystemMessage/SystemMessage.tsx +++ b/src/script/components/MessagesList/Message/SystemMessage/SystemMessage.tsx @@ -19,7 +19,10 @@ import React from 'react'; +import {MLSVerified} from '@wireapp/react-ui-kit'; + import {Icon} from 'Components/Icon'; +import {E2EIVerificationMessage} from 'src/script/entity/message/E2EIVerificationMessage'; import {JoinedAfterMLSMigrationFinalisationMessage} from 'src/script/entity/message/JoinedAfterMLSMigrationFinalisationMessage'; import {MessageTimerUpdateMessage} from 'src/script/entity/message/MessageTimerUpdateMessage'; import {MLSConversationRecoveredMessage} from 'src/script/entity/message/MLSConversationRecoveredMessage'; @@ -59,6 +62,10 @@ export const SystemMessage: React.FC = ({message}) => { return } />; } + if (message instanceof E2EIVerificationMessage) { + return } />; + } + if (message instanceof ProtocolUpdateMessage) { return ; } diff --git a/src/script/components/DeviceId.tsx b/src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.styles.ts similarity index 58% rename from src/script/components/DeviceId.tsx rename to src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.styles.ts index 2fbd2793343..8712a7cf1ad 100644 --- a/src/script/components/DeviceId.tsx +++ b/src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.styles.ts @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2021 Wire Swiss GmbH + * Copyright (C) 2023 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,24 +17,24 @@ * */ -import React from 'react'; +import {CSSObject} from '@emotion/serialize'; -import {splitFingerprint} from 'Util/StringUtil'; - -export interface DeviceIdProps { - deviceId: string; +interface Styles { + content: CSSObject; + modalWrapper: CSSObject; } -const DeviceId: React.FC = ({deviceId}) => { - return ( - <> - {splitFingerprint(deviceId).map((id, index) => ( - - {id} - - ))} - - ); +export const styles: Styles = { + modalWrapper: { + maxWidth: '460px', + width: '100%', + }, + content: { + overflow: 'auto', + maxHeight: '251px', + fontSize: 'var(--font-size-small)', + letterSpacing: '0.05px', + lineHeight: 'var(--line-height-md)', + wordBreak: 'break-word', + }, }; - -export {DeviceId}; diff --git a/src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.test.tsx b/src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.test.tsx new file mode 100644 index 00000000000..213b73ca639 --- /dev/null +++ b/src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.test.tsx @@ -0,0 +1,70 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {fireEvent, render, waitFor} from '@testing-library/react'; + +import {CertificateDetailsModal} from './CertificateDetailsModal'; + +const certificate: string = + '-----BEGIN CERTIFICATE-----MIICDzCCAbQCCQDzlU3qembswDAKBggqhkjOPQQDAjCBjjELMAkGA1UEBhMCREUxDzANBgNVBAgMBkJlcmxpbjEOMAwGA1UEBwwFTWl0dGUxDTALBgNVBAoMBFdpcmUxFDASBgNVBAsMC0VuZ2luZWVyaW5nMRQwEgYDVQQDDAtjYS53aXJlLmNvbTEjMCEGCSqGSIb3DQEJARYUZW5naW5lZXJpbmdAd2lyZS5jb20wHhcNMjMwNTE2MTcxNjE3WhcNMjMwNjE1MTcxNjE3WjCBjjELMAkGA1UEBhMCREUxDzANBgNVBAgMBkJlcmxpbjEOMAwGA1UEBwwFTWl0dGUxDTALBgNVBAoMBFdpcmUxFDASBgNVBAsMC0VuZ2luZWVyaW5nMRQwEgYDVQQDDAtjYS53aXJlLmNvbTEjMCEGCSqGSIb3DQEJARYUZW5naW5lZXJpbmdAd2lyZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASpxQ3hrzh5fPDan+vbcRT8fCQzaz3fIywUNxTRWzvGpPkRPPDegJ7h4G6aUqDfZFgvSsCCaaGaYYVF1di/tuYpMAoGCCqGSM49BAMCA0kAMEYCIQDHAeMUcjjP5J3Mbs3uIlPLd0tZQb0S6bEekXvHsxhYGAIhAKOoeyMqaHxj3qaHnpCBjY/0slt2QUbtDbpF3Lgz2l2S-----END CERTIFICATE-----'; + +Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockImplementation(() => Promise.resolve()), + }, +}); + +Object.assign(window, { + URL: { + revokeObjectURL: jest.fn(), + }, +}); + +const defaultProps = { + certificate, + onClose: jest.fn(), +}; + +describe('CertificateDetailsModal', () => { + it('is certificate downloaded', async () => { + const {getByTestId} = render(); + + const downloadButton = getByTestId('download-certificate') as HTMLButtonElement; + expect(downloadButton).toBeDefined(); + expect(downloadButton.disabled).toBe(false); + + fireEvent.click(downloadButton); + + expect(downloadButton.disabled).toBe(true); + }); + + it('is certificate copied', async () => { + const {getByTestId} = render(); + + const copyButton = getByTestId('copy-certificate') as HTMLButtonElement; + expect(copyButton).toBeDefined(); + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(certificate); + + await waitFor(() => { + expect(copyButton.disabled).toBe(true); + }); + }); +}); diff --git a/src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.tsx b/src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.tsx new file mode 100644 index 00000000000..d38811cb6b9 --- /dev/null +++ b/src/script/components/Modals/CertificateDetailsModal/CertificateDetailsModal.tsx @@ -0,0 +1,113 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useState} from 'react'; + +import {Icon} from 'Components/Icon'; +import {ModalComponent} from 'Components/ModalComponent'; +import {t} from 'Util/LocalizerUtil'; +import {downloadFile} from 'Util/util'; + +import {styles} from './CertificateDetailsModal.styles'; + +const COPY_MESSAGE_TIMEOUT = 3000; +const DOWNLOAD_CERTIFICATE_TIMEOUT = 500; +const CERTIFICATE_NAME = 'certificate.pem'; +const CERTIFICATE_TYPE = 'application/x-pem-file'; + +export interface CertificateDetailsModalProps { + certificate: string; + onClose: () => void; +} + +export const CertificateDetailsModal = ({certificate, onClose}: CertificateDetailsModalProps) => { + const [isTextCopied, setIsTextCopied] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + + const onDownload = () => { + setIsDownloading(true); + + const certificateUrl = `data:${CERTIFICATE_TYPE},${encodeURIComponent(certificate)}`; + downloadFile(certificateUrl, CERTIFICATE_NAME, CERTIFICATE_TYPE); + + setTimeout(() => { + setIsDownloading(false); + }, DOWNLOAD_CERTIFICATE_TIMEOUT); + }; + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(certificate).then(() => { + setIsTextCopied(true); + + setTimeout(() => { + setIsTextCopied(false); + }, COPY_MESSAGE_TIMEOUT); + }); + } catch (err) { + console.error('Failed to copy: ', err); + } + }; + + return ( + +
+

+ {t('E2EI.certificateDetails')} +

+ + +
+ +
+ {certificate} +
+ +
+ + + +
+
+ ); +}; diff --git a/src/script/components/Modals/CertificateDetailsModal/index.ts b/src/script/components/Modals/CertificateDetailsModal/index.ts new file mode 100644 index 00000000000..d8baa78a360 --- /dev/null +++ b/src/script/components/Modals/CertificateDetailsModal/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './CertificateDetailsModal'; diff --git a/src/script/components/Modals/UserModal/UserModal.tsx b/src/script/components/Modals/UserModal/UserModal.tsx index cee5bd677fb..c52e5e6db48 100644 --- a/src/script/components/Modals/UserModal/UserModal.tsx +++ b/src/script/components/Modals/UserModal/UserModal.tsx @@ -146,11 +146,10 @@ const UserModal: React.FC = ({ resetState(); }; const {classifiedDomains} = useKoSubscribableChildren(teamState, ['classifiedDomains']); - const { - is_trusted: isTrusted, - is_verified: isSelfVerified, - isActivatedAccount, - } = useKoSubscribableChildren(selfUser, ['is_trusted', 'is_verified', 'isActivatedAccount']); + const {is_trusted: isTrusted, isActivatedAccount} = useKoSubscribableChildren(selfUser, [ + 'is_trusted', + 'isActivatedAccount', + ]); const isFederated = core.backendFeatures?.isFederated; useEffect(() => { @@ -203,14 +202,7 @@ const UserModal: React.FC = ({
{user && ( <> - + diff --git a/src/script/components/ParticipantItemContent/ParticipantItemContent.tsx b/src/script/components/ParticipantItemContent/ParticipantItemContent.tsx index 44c566c235d..c9b5e0d767d 100644 --- a/src/script/components/ParticipantItemContent/ParticipantItemContent.tsx +++ b/src/script/components/ParticipantItemContent/ParticipantItemContent.tsx @@ -19,10 +19,11 @@ import React from 'react'; -import {Availability} from '@wireapp/protocol-messaging'; - import {AvailabilityState} from 'Components/AvailabilityState'; import {Icon} from 'Components/Icon'; +import {User} from 'src/script/entity/User'; +import {ServiceEntity} from 'src/script/integration/ServiceEntity'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import { contentInfoWrapper, @@ -38,45 +39,59 @@ import { } from './ParticipantItem.styles'; export interface ParticipantItemContentProps { - name: string; + renderParticipantBadges?: (user: User) => React.ReactNode; + participant: User | ServiceEntity; selfInTeam?: boolean; - availability?: Availability.Type; shortDescription?: string; selfString?: string; hasUsernameInfo?: boolean; showArrow?: boolean; onDropdownClick?: (event: React.MouseEvent) => void; showAvailabilityState?: boolean; + isSelectable?: boolean; + isProteusVerified?: boolean; + isMLSVerified?: boolean; } export const ParticipantItemContent = ({ - name, + renderParticipantBadges, + participant, selfInTeam = false, - availability = Availability.Type.NONE, shortDescription = '', selfString = '', hasUsernameInfo = false, showArrow = false, showAvailabilityState = false, + isSelectable = false, }: ParticipantItemContentProps) => { + const {name} = useKoSubscribableChildren(participant, ['name']); + + const isService = participant instanceof ServiceEntity; + return (
- {showAvailabilityState && selfInTeam ? ( + {!isService && showAvailabilityState && selfInTeam ? ( + selfString={selfString} + > + {!isSelectable && renderParticipantBadges?.(participant)} + ) : ( -
- {name} -
- )} + <> +
+ {name} + + {selfString && {selfString}} +
- {selfString &&
{selfString}
} + {!isSelectable && !isService && renderParticipantBadges?.(participant)} + + )}
{shortDescription && ( diff --git a/src/script/components/ServiceList/components/ServiceListItem/ServiceListItem.tsx b/src/script/components/ServiceList/components/ServiceListItem/ServiceListItem.tsx index 56242784325..c20fbc7b5f7 100644 --- a/src/script/components/ServiceList/components/ServiceListItem/ServiceListItem.tsx +++ b/src/script/components/ServiceList/components/ServiceListItem/ServiceListItem.tsx @@ -53,7 +53,7 @@ export const ServiceListItem = ({service, onClick}: ServiceListItemProps) => {
); diff --git a/src/script/components/TitleBar/TitleBar.tsx b/src/script/components/TitleBar/TitleBar.tsx index 0ca5899eb21..ea5d8193477 100644 --- a/src/script/components/TitleBar/TitleBar.tsx +++ b/src/script/components/TitleBar/TitleBar.tsx @@ -30,6 +30,7 @@ import {WebAppEvents} from '@wireapp/webapp-events'; import {useCallAlertState} from 'Components/calling/useCallAlertState'; import {Icon} from 'Components/Icon'; import {LegalHoldDot} from 'Components/LegalHoldDot'; +import {ConversationVerificationBadges} from 'Components/VerificationBadge'; import {User} from 'src/script/entity/User'; import {useAppMainState, ViewType} from 'src/script/page/state'; import {ContentState} from 'src/script/page/useAppState'; @@ -41,7 +42,6 @@ import {TIME_IN_MILLIS} from 'Util/TimeUtil'; import {CallState} from '../../calling/CallState'; import {ConversationFilter} from '../../conversation/ConversationFilter'; -import {ConversationVerificationState} from '../../conversation/ConversationVerificationState'; import {Conversation} from '../../entity/Conversation'; import {RightSidebarParams} from '../../page/AppMain'; import {PanelState} from '../../page/RightSidebar/RightSidebar'; @@ -87,7 +87,6 @@ export const TitleBar: React.FC = ({ firstUserEntity, hasLegalHold, display_name: displayName, - verification_state: verificationState, } = useKoSubscribableChildren(conversation, [ 'is1to1', 'isRequest', @@ -100,7 +99,6 @@ export const TitleBar: React.FC = ({ 'firstUserEntity', 'hasLegalHold', 'display_name', - 'verification_state', ]); const {isActivatedAccount} = useKoSubscribableChildren(selfUser, ['isActivatedAccount']); @@ -268,16 +266,11 @@ export const TitleBar: React.FC = ({ /> )} - {verificationState === ConversationVerificationState.VERIFIED && ( - - )} - {displayName} + +
{conversationSubtitle &&
{conversationSubtitle}
} diff --git a/src/script/components/UserDevices.tsx b/src/script/components/UserDevices.tsx index 0bba4ee02e2..26ea277a512 100644 --- a/src/script/components/UserDevices.tsx +++ b/src/script/components/UserDevices.tsx @@ -32,10 +32,10 @@ import {NoDevicesFound} from './userDevices/NoDevicesFound'; import {SelfFingerprint} from './userDevices/SelfFingerprint'; import {ClientRepository, ClientEntity} from '../client'; -import {ConversationState} from '../conversation/ConversationState'; import {MessageRepository} from '../conversation/MessageRepository'; import {CryptographyRepository} from '../cryptography/CryptographyRepository'; import {User} from '../entity/User'; +import {useUserIdentity} from '../hooks/useDeviceIdentities'; enum FIND_MODE { FOUND = 'UserDevices.MODE.FOUND', @@ -80,13 +80,13 @@ const sortUserDevices = (devices: ClientEntity[]): ClientEntity[] => { interface UserDevicesProps { clientRepository: ClientRepository; - conversationState?: ConversationState; cryptographyRepository: CryptographyRepository; current: UserDevicesHistoryEntry; goTo: (state: UserDevicesState, headline: string) => void; messageRepository: MessageRepository; noPadding?: boolean; user: User; + groupId?: string; } const UserDevices: React.FC = ({ @@ -97,8 +97,10 @@ const UserDevices: React.FC = ({ goTo, messageRepository, cryptographyRepository, + groupId, }) => { const [selectedClient, setSelectedClient] = useState(); + const {getDeviceIdentity} = useUserIdentity(user.qualifiedId, groupId); const [deviceMode, setDeviceMode] = useState(FIND_MODE.REQUESTING); const [clients, setClients] = useState([]); const logger = useMemo(() => getLogger('UserDevicesComponent'), []); @@ -136,21 +138,22 @@ const UserDevices: React.FC = ({ return (
{showDeviceList && deviceMode === FIND_MODE.FOUND && ( - + )} {showDeviceList && deviceMode === FIND_MODE.NOT_FOUND && } - {current.state === UserDevicesState.DEVICE_DETAILS && ( + {current.state === UserDevicesState.DEVICE_DETAILS && selectedClient && ( diff --git a/src/script/components/UserList/UserList.tsx b/src/script/components/UserList/UserList.tsx index 63dd77a20a9..51cee816244 100644 --- a/src/script/components/UserList/UserList.tsx +++ b/src/script/components/UserList/UserList.tsx @@ -52,6 +52,7 @@ const USER_CHUNK_SIZE = 64; export interface UserListProps { conversation?: Conversation; + renderParticipantBadges?: (user: User) => React.ReactNode; conversationRepository?: ConversationRepository; conversationState?: ConversationState; highlightedUsers?: User[]; @@ -75,6 +76,7 @@ export interface UserListProps { export const UserList = ({ onClick, + renderParticipantBadges, conversationRepository, users, infos, @@ -130,6 +132,7 @@ export const UserList = ({ return (
  • React.ReactNode; canSelect: boolean; customInfo?: string; external: boolean; @@ -53,14 +54,18 @@ export interface UserListItemProps { showArrow: boolean; } -const UserListItem = ({ +interface RenderParticipantProps { + isSelectable?: boolean; +} + +export const UserListItem = ({ + renderParticipantBadges, canSelect, customInfo, external, hideInfo, isHighlighted, isSelected, - isSelfVerified = false, mode = UserlistMode.DEFAULT, noInteraction, noUnderline = false, @@ -71,12 +76,7 @@ const UserListItem = ({ }: UserListItemProps) => { const checkboxId = useId(); - const { - is_verified: isVerified, - isDirectGuest, - availability, - expirationText, - } = useKoSubscribableChildren(user, ['isDirectGuest', 'is_verified', 'availability', 'expirationText', 'name']); + const {isDirectGuest, expirationText} = useKoSubscribableChildren(user, ['isDirectGuest', 'expirationText']); const {isMe: isSelf, isFederated} = user; const isTemporaryGuest = user.isTemporaryGuest(); @@ -110,19 +110,20 @@ const UserListItem = ({ const contentInfoText = getContentInfoText(); - const RenderParticipant = () => { + const RenderParticipant = ({isSelectable = false}: RenderParticipantProps) => { return (
    @@ -159,7 +159,7 @@ const UserListItem = ({ >
    - +
    @@ -182,5 +182,3 @@ const UserListItem = ({ ); }; - -export {UserListItem}; diff --git a/src/script/components/UserSearchableList.tsx b/src/script/components/UserSearchableList.tsx index 40948c4fb5a..100d9ef7209 100644 --- a/src/script/components/UserSearchableList.tsx +++ b/src/script/components/UserSearchableList.tsx @@ -56,7 +56,7 @@ export type UserListProps = React.ComponentProps & { allowRemoteSearch?: boolean; }; -const UserSearchableList: React.FC = ({ +export const UserSearchableList: React.FC = ({ onUpdateSelectedUsers, dataUieName = '', filter = '', @@ -169,5 +169,3 @@ const UserSearchableList: React.FC = ({
  • ); }; - -export {UserSearchableList}; diff --git a/src/script/components/VerificationBadge/VerificationBadges.test.tsx b/src/script/components/VerificationBadge/VerificationBadges.test.tsx new file mode 100644 index 00000000000..477aeb5df87 --- /dev/null +++ b/src/script/components/VerificationBadge/VerificationBadges.test.tsx @@ -0,0 +1,62 @@ +/* + * Wire + * Copyright (C) 2022 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {render} from '@testing-library/react'; + +import {withTheme} from 'src/script/auth/util/test/TestUtil'; +import {MLSStatuses} from 'src/script/E2EIdentity'; + +import {VerificationBadges} from './VerificationBadges'; + +describe('VerificationBadges', () => { + it('is mls verified', async () => { + const {getByTestId} = render(withTheme()); + + const E2EIdentityStatus = getByTestId('mls-status'); + expect(E2EIdentityStatus.getAttribute('data-uie-value')).toEqual(MLSStatuses.VALID); + }); + + it('is proteus verified', async () => { + const {getByTestId} = render(withTheme()); + + const E2EIdentityStatus = getByTestId('proteus-verified'); + expect(E2EIdentityStatus).not.toBeNull(); + }); + + it('is not downloaded', async () => { + const {getByTestId} = render(withTheme()); + + const E2EIdentityStatus = getByTestId('mls-status'); + expect(E2EIdentityStatus.getAttribute('data-uie-value')).toEqual(MLSStatuses.NOT_DOWNLOADED); + }); + + it('is expired', async () => { + const {getByTestId} = render(withTheme()); + + const E2EIdentityStatus = getByTestId('mls-status'); + expect(E2EIdentityStatus.getAttribute('data-uie-value')).toEqual(MLSStatuses.EXPIRED); + }); + + it('is expires soon', async () => { + const {getByTestId} = render(withTheme()); + + const E2EIdentityStatus = getByTestId('mls-status'); + expect(E2EIdentityStatus.getAttribute('data-uie-value')).toEqual(MLSStatuses.EXPIRES_SOON); + }); +}); diff --git a/src/script/components/VerificationBadge/VerificationBadges.tsx b/src/script/components/VerificationBadge/VerificationBadges.tsx new file mode 100644 index 00000000000..9ce50731172 --- /dev/null +++ b/src/script/components/VerificationBadge/VerificationBadges.tsx @@ -0,0 +1,206 @@ +/* + * Wire + * Copyright (C) 2018 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSProperties} from 'react'; + +import {ConversationProtocol} from '@wireapp/api-client/lib/conversation'; + +import { + CertificateExpiredIcon, + ExpiresSoon, + CertificateRevoked, + MLSVerified, + ProteusVerified, +} from '@wireapp/react-ui-kit'; + +import {ClientEntity} from 'src/script/client'; +import {ConversationVerificationState} from 'src/script/conversation/ConversationVerificationState'; +import {MLSStatuses, WireIdentity} from 'src/script/E2EIdentity/E2EIdentityVerification'; +import {Conversation} from 'src/script/entity/Conversation'; +import {User} from 'src/script/entity/User'; +import {useUserIdentity} from 'src/script/hooks/useDeviceIdentities'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {t} from 'Util/LocalizerUtil'; + +interface VerificationBadgesProps { + conversationProtocol?: ConversationProtocol; + isProteusVerified?: boolean; + MLSStatus?: MLSStatuses; + displayTitle?: boolean; +} + +const badgeWrapper: CSSProperties = { + display: 'flex', + alignItems: 'center', +}; + +const iconStyles: CSSProperties = { + display: 'flex', + alignItems: 'center', +}; + +const title = (isMLSConversation = false): CSSProperties => ({ + color: isMLSConversation ? 'var(--green-500)' : 'var(--blue-500)', + fontSize: '12px', + lineHeight: '14px', + marginRight: '4px', +}); + +const useConversationVerificationState = (conversation: Conversation) => { + const {verification_state: proteusVerificationState, mlsVerificationState} = useKoSubscribableChildren(conversation, [ + 'verification_state', + 'mlsVerificationState', + ]); + const mlsState = mlsVerificationState === ConversationVerificationState.VERIFIED ? MLSStatuses.VALID : undefined; + return {MLS: mlsState, proteus: proteusVerificationState}; +}; + +export const UserVerificationBadges = ({user, groupId}: {user: User; groupId?: string}) => { + const {status: MLSStatus} = useUserIdentity(user.qualifiedId, groupId); + const {is_verified: isProteusVerified} = useKoSubscribableChildren(user, ['is_verified']); + + return ; +}; + +export const DeviceVerificationBadges = ({ + device, + getIdentity, +}: { + device: ClientEntity; + getIdentity?: (deviceId: string) => WireIdentity | undefined; +}) => { + const MLSStatus = getIdentity ? getIdentity(device.id)?.status ?? MLSStatuses.NOT_DOWNLOADED : undefined; + + return ; +}; + +type ConversationVerificationBadgeProps = { + conversation: Conversation; + displayTitle?: boolean; +}; +export const ConversationVerificationBadges = ({conversation, displayTitle}: ConversationVerificationBadgeProps) => { + const verificationState = useConversationVerificationState(conversation); + + return ( + + ); +}; + +export const VerificationBadges = ({ + conversationProtocol, + isProteusVerified = false, + MLSStatus, + displayTitle = false, +}: VerificationBadgesProps) => { + if (!MLSStatus && !isProteusVerified) { + return null; + } + + const isExpired = MLSStatus === MLSStatuses.EXPIRED; + const isNotDownloaded = MLSStatus === MLSStatuses.NOT_DOWNLOADED; + const isExpiresSoon = MLSStatus === MLSStatuses.EXPIRES_SOON; + + const conversationHasProtocol = !!conversationProtocol; + + const showMLSBadge = conversationHasProtocol + ? conversationProtocol === ConversationProtocol.MLS && !!MLSStatus + : !!MLSStatus; + + const showProteusBadge = conversationHasProtocol + ? conversationProtocol === ConversationProtocol.PROTEUS && isProteusVerified + : isProteusVerified; + + return ( +
    + {showMLSBadge && ( +
    + {displayTitle && {t('E2EI.verified')}} + + {!isExpired && !isNotDownloaded && !isExpiresSoon && ( + + + + )} + + {isExpired && ( + + + + )} + + {isExpiresSoon && ( + + + + )} + + {isNotDownloaded && ( + + + + )} +
    + )} + + {showProteusBadge && ( +
    + {displayTitle && {t('proteusVerifiedDetails')}} + + + + +
    + )} +
    + ); +}; diff --git a/src/script/components/VerificationBadge/index.ts b/src/script/components/VerificationBadge/index.ts new file mode 100644 index 00000000000..084f8ca3023 --- /dev/null +++ b/src/script/components/VerificationBadge/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2022 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './VerificationBadges'; diff --git a/src/script/components/calling/CallParticipantsListItem/CallParticipantItemContent/CallParticipantItemContent.tsx b/src/script/components/calling/CallParticipantsListItem/CallParticipantItemContent/CallParticipantItemContent.tsx index 4ebdc04ef49..77c6bdc31aa 100644 --- a/src/script/components/calling/CallParticipantsListItem/CallParticipantItemContent/CallParticipantItemContent.tsx +++ b/src/script/components/calling/CallParticipantsListItem/CallParticipantItemContent/CallParticipantItemContent.tsx @@ -21,10 +21,10 @@ import React from 'react'; import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; -import {Availability} from '@wireapp/protocol-messaging'; - import {AvailabilityState} from 'Components/AvailabilityState'; import {Icon} from 'Components/Icon'; +import {User} from 'src/script/entity/User'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; import {capitalizeFirstChar} from 'Util/StringUtil'; @@ -40,24 +40,23 @@ import { } from './CallParticipantItemContent.styles'; export interface CallParticipantItemContentProps { - name: string; + user: User; selfInTeam?: boolean; isAudioEstablished: boolean; - availability?: Availability.Type; isSelf?: boolean; showContextMenu: boolean; onDropdownClick: (event: React.MouseEvent) => void; } export const CallParticipantItemContent = ({ - name, + user, selfInTeam = false, isAudioEstablished, - availability = Availability.Type.NONE, isSelf = false, showContextMenu, onDropdownClick, }: CallParticipantItemContentProps) => { + const {name} = useKoSubscribableChildren(user, ['name']); const selfString = `(${capitalizeFirstChar(t('conversationYouNominative'))})`; return ( @@ -65,12 +64,7 @@ export const CallParticipantItemContent = ({
    {selfInTeam ? ( - + ) : (
    {name} diff --git a/src/script/components/calling/CallParticipantsListItem/CallParticipantsListItem.tsx b/src/script/components/calling/CallParticipantsListItem/CallParticipantsListItem.tsx index bce9fcd2b2a..98931c179f7 100644 --- a/src/script/components/calling/CallParticipantsListItem/CallParticipantsListItem.tsx +++ b/src/script/components/calling/CallParticipantsListItem/CallParticipantsListItem.tsx @@ -63,10 +63,9 @@ export const CallParticipantsListItem = ({ const { isDirectGuest, is_verified: isVerified, - availability, name: userName, isExternal, - } = useKoSubscribableChildren(user, ['isDirectGuest', 'is_verified', 'availability', 'name', 'isExternal']); + } = useKoSubscribableChildren(user, ['isDirectGuest', 'is_verified', 'name', 'isExternal']); const handleContextKeyDown = (event: React.KeyboardEvent) => { handleKeyDown(event, () => { @@ -103,9 +102,8 @@ export const CallParticipantsListItem = ({ onContextMenu?.(event as unknown as React.MouseEvent)} diff --git a/src/script/components/list/ConversationListCell.tsx b/src/script/components/list/ConversationListCell.tsx index 06dee945c54..31efbd4e564 100644 --- a/src/script/components/list/ConversationListCell.tsx +++ b/src/script/components/list/ConversationListCell.tsx @@ -29,8 +29,6 @@ import React, { import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; import cx from 'classnames'; -import {Availability} from '@wireapp/protocol-messaging'; - import {AvailabilityState} from 'Components/AvailabilityState'; import {Avatar, AVATAR_SIZE, GroupAvatar} from 'Components/Avatar'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; @@ -77,7 +75,6 @@ const ConversationListCell = ({ participating_user_ets: users, display_name: displayName, removed_from_conversation: removedFromConversation, - availabilityOfUser, unreadState, mutedState, isRequest, @@ -88,7 +85,6 @@ const ConversationListCell = ({ 'participating_user_ets', 'display_name', 'removed_from_conversation', - 'availabilityOfUser', 'unreadState', 'mutedState', 'isRequest', @@ -157,20 +153,6 @@ const ConversationListCell = ({ } }, [isFocused]); - const availabilityStrings: Record = { - [Availability.Type.AVAILABLE]: t('userAvailabilityAvailable'), - [Availability.Type.AWAY]: t('userAvailabilityAway'), - [Availability.Type.BUSY]: t('userAvailabilityBusy'), - }; - const availabilityTitle = [Availability.Type.AWAY, Availability.Type.BUSY, Availability.Type.AVAILABLE].includes( - availabilityOfUser, - ) - ? t('accessibility.conversationTitle', { - username: displayName, - status: availabilityStrings[availabilityOfUser], - }) - : displayName; - return (
  • ) : ( diff --git a/src/script/components/panel/UserDetails.test.tsx b/src/script/components/panel/UserDetails.test.tsx index d9ecfe43a54..89a0a0e798c 100644 --- a/src/script/components/panel/UserDetails.test.tsx +++ b/src/script/components/panel/UserDetails.test.tsx @@ -24,7 +24,6 @@ import {createUuid} from 'Util/uuid'; import {UserDetails} from './UserDetails'; -import {ClientEntity} from '../../client/ClientEntity'; import {User} from '../../entity/User'; describe('UserDetails', () => { @@ -54,20 +53,6 @@ describe('UserDetails', () => { expect(queryByTestId('status-admin')).toBeNull(); }); - it('shows a verified icon when all clients from the self user are verified and all clients of the other participant are verified', () => { - const otherParticipant = new User(createUuid()); - const verifiedClient = new ClientEntity(false, null); - verifiedClient.meta.isVerified?.(true); - otherParticipant.devices.push(verifiedClient); - - const props = {isGroupAdmin: true, isSelfVerified: true, participant: otherParticipant}; - - const {queryByTestId} = render(); - - expect(queryByTestId('status-verified-participant')).not.toBeNull(); - expect(queryByTestId('status-admin')).not.toBeNull(); - }); - it('renders the badge for a user', () => { const badge = 'badgeText'; const participant = new User(createUuid()); diff --git a/src/script/components/panel/UserDetails.tsx b/src/script/components/panel/UserDetails.tsx index becc0b73d6a..ab6074ca1e2 100644 --- a/src/script/components/panel/UserDetails.tsx +++ b/src/script/components/panel/UserDetails.tsx @@ -39,9 +39,9 @@ import {User} from '../../entity/User'; interface UserDetailsProps { badge?: string; + renderParticipantBadges?: (user: User) => React.ReactNode; classifiedDomains?: string[]; isGroupAdmin?: boolean; - isSelfVerified: boolean; isVerified?: boolean; participant: User; avatarStyles?: React.CSSProperties; @@ -51,21 +51,13 @@ interface UserDetailsProps { const UserDetailsComponent: React.FC = ({ badge, participant, - isSelfVerified, + renderParticipantBadges, isGroupAdmin, avatarStyles, classifiedDomains, teamState = container.resolve(TeamState), }) => { - const user = useKoSubscribableChildren(participant, [ - 'isGuest', - 'isTemporaryGuest', - 'expirationText', - 'name', - 'availability', - 'is_verified', - 'isAvailable', - ]); + const user = useKoSubscribableChildren(participant, ['isGuest', 'isTemporaryGuest', 'expirationText', 'isAvailable']); useEffect(() => { // This will trigger a user refresh @@ -76,12 +68,9 @@ const UserDetailsComponent: React.FC = ({
    {teamState.isInTeam(participant) ? ( - + + {renderParticipantBadges?.(participant)} + ) : (

    = ({

    )} - - {isSelfVerified && user.is_verified && ( - - )}
    {participant.handle && ( diff --git a/src/script/components/userDevices/DeviceCard.test.tsx b/src/script/components/userDevices/DeviceCard.test.tsx index 8bb866686f1..75ab4d0265b 100644 --- a/src/script/components/userDevices/DeviceCard.test.tsx +++ b/src/script/components/userDevices/DeviceCard.test.tsx @@ -37,36 +37,6 @@ function createClientEntity(clientEntity: Partial): ClientEntity { } describe('DeviceCard', () => { - it('renders desktop icon for desktop clients', async () => { - const props = { - device: createClientEntity({ - class: ClientClassification.DESKTOP, - meta: { - isVerified: ko.observable(false), - }, - }), - showIcon: true, - }; - - const {getByTestId} = render(); - expect(getByTestId('status-desktop-device')).not.toBeNull(); - }); - - it('renders mobile devices icon for non-desktop clients', async () => { - const props = { - device: createClientEntity({ - class: ClientClassification.PHONE, - meta: { - isVerified: ko.observable(false), - }, - }), - showIcon: true, - }; - - const {getByTestId} = render(); - expect(getByTestId('status-mobile-device')).not.toBeNull(); - }); - it('shows disclose icon when component is clickable', async () => { const props = { click: jest.fn(), @@ -82,36 +52,4 @@ describe('DeviceCard', () => { const {getByTestId} = render(); expect(getByTestId('disclose-icon')).not.toBeNull(); }); - - it('shows verified icon', async () => { - const props = { - device: createClientEntity({ - class: ClientClassification.PHONE, - meta: { - isVerified: ko.observable(true), - }, - }), - showIcon: true, - showVerified: true, - }; - - const {getByTestId} = render(); - expect(getByTestId('user-device-verified')).not.toBeNull(); - }); - - it('shows unverified icon', async () => { - const props = { - device: createClientEntity({ - class: ClientClassification.PHONE, - meta: { - isVerified: ko.observable(false), - }, - }), - showIcon: true, - showVerified: true, - }; - - const {getByTestId} = render(); - expect(getByTestId('user-device-not-verified')).not.toBeNull(); - }); }); diff --git a/src/script/components/userDevices/DeviceCard.tsx b/src/script/components/userDevices/DeviceCard.tsx index 86abd62036f..c31c9a838e1 100644 --- a/src/script/components/userDevices/DeviceCard.tsx +++ b/src/script/components/userDevices/DeviceCard.tsx @@ -17,42 +17,38 @@ * */ -import React from 'react'; - import {ClientClassification} from '@wireapp/api-client/lib/client'; import cx from 'classnames'; -import {DeviceId} from 'Components/DeviceId'; import {useMessageFocusedTabIndex} from 'Components/MessagesList/Message/util'; +import {DeviceVerificationBadges} from 'Components/VerificationBadge'; +import {WireIdentity} from 'src/script/E2EIdentity'; import {handleKeyDown} from 'Util/KeyboardUtil'; import {t} from 'Util/LocalizerUtil'; +import {splitFingerprint} from 'Util/StringUtil'; -import type {ClientEntity} from '../../client/ClientEntity'; +import {type ClientEntity} from '../../client/ClientEntity'; +import {FormattedId} from '../../page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId'; import {Icon} from '../Icon'; import {LegalHoldDot} from '../LegalHoldDot'; -import {VerifiedIcon} from '../VerifiedIcon'; export interface DeviceCardProps { click?: (device: ClientEntity) => void; + getDeviceIdentity?: (deviceId: string) => WireIdentity | undefined; device: ClientEntity; showIcon?: boolean; showVerified?: boolean; } -const DeviceCard: React.FC = ({ - click, - device: clientEntity, - showVerified = false, - showIcon = false, -}) => { +const DeviceCard = ({click, getDeviceIdentity, device: clientEntity, showIcon = false}: DeviceCardProps) => { const messageFocusedTabIndex = useMessageFocusedTabIndex(!!click); - const {class: deviceClass = '?', id = '', label = '?', meta} = clientEntity; + const {class: deviceClass = '?', id = '', label = '?'} = clientEntity; const name = clientEntity.getName(); const clickable = !!click; - const isVerified = meta.isVerified; + + const deviceIdentity = getDeviceIdentity?.(clientEntity.id); + const showLegalHoldIcon = showIcon && deviceClass === ClientClassification.LEGAL_HOLD; - const showDesktopIcon = showIcon && deviceClass === ClientClassification.DESKTOP; - const showMobileIcon = showIcon && !showLegalHoldIcon && !showDesktopIcon; const clickOnDevice = () => { if (clickable) { @@ -76,20 +72,32 @@ const DeviceCard: React.FC = ({ dataUieName="status-legal-hold-device" /> )} - {showDesktopIcon && } - {showMobileIcon && } +
    -
    +
    {name} +
    -

    + + {deviceIdentity?.thumbprint && ( +

    + {t('preferencesMLSThumbprint')} + + + + +

    + )} + +

    {t('preferencesDevicesId')} - - + + +

    - {showVerified && } + {clickable && }
    ); diff --git a/src/script/components/userDevices/DeviceDetails.tsx b/src/script/components/userDevices/DeviceDetails.tsx index 126fec3705b..5d655753874 100644 --- a/src/script/components/userDevices/DeviceDetails.tsx +++ b/src/script/components/userDevices/DeviceDetails.tsx @@ -17,19 +17,19 @@ * */ -import React, {useEffect, useMemo, useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; import cx from 'classnames'; import type {DexieError} from 'dexie'; import {container} from 'tsyringe'; -import {Icon} from 'Components/Icon'; +import {Button, ButtonVariant} from '@wireapp/react-ui-kit'; + import {isMLSConversation} from 'src/script/conversation/ConversationSelectors'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; import type {Logger} from 'Util/Logger'; - -import {DeviceCard} from './DeviceCard'; +import {splitFingerprint} from 'Util/StringUtil'; import type {ClientRepository, ClientEntity} from '../../client'; import {Config} from '../../Config'; @@ -38,7 +38,7 @@ import type {MessageRepository} from '../../conversation/MessageRepository'; import type {CryptographyRepository} from '../../cryptography/CryptographyRepository'; import type {User} from '../../entity/User'; import {MotionDuration} from '../../motion/MotionDuration'; -import {DeviceId} from '../DeviceId'; +import {FormattedId} from '../../page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId'; interface DeviceDetailsProps { clickToShowSelfFingerprint: () => void; @@ -48,12 +48,12 @@ interface DeviceDetailsProps { logger: Logger; messageRepository: MessageRepository; noPadding: boolean; - selectedClient: ClientEntity; + device: ClientEntity; user: User; } -const DeviceDetails: React.FC = ({ - selectedClient, +export const DeviceDetails = ({ + device, cryptographyRepository, user, clickToShowSelfFingerprint, @@ -62,28 +62,28 @@ const DeviceDetails: React.FC = ({ noPadding, logger, conversationState = container.resolve(ConversationState), -}) => { +}: DeviceDetailsProps) => { const [fingerprintRemote, setFingerprintRemote] = useState(); const [isResettingSession, setIsResettingSession] = useState(false); - const clientMeta = useMemo(() => selectedClient?.meta, [selectedClient]); + const clientMeta = useMemo(() => device?.meta, [device]); const {isVerified} = useKoSubscribableChildren(clientMeta, ['isVerified']); const {name: userName} = useKoSubscribableChildren(user, ['name']); useEffect(() => { setFingerprintRemote(undefined); - if (selectedClient) { - cryptographyRepository - .getRemoteFingerprint(user.qualifiedId, selectedClient.id) + if (device) { + void cryptographyRepository + .getRemoteFingerprint(user.qualifiedId, device.id) .then(remoteFingerprint => setFingerprintRemote(remoteFingerprint)); } - }, [selectedClient]); + }, [device]); const clickToToggleDeviceVerification = () => { const toggleVerified = !isVerified; clientRepository - .verifyClient(user.qualifiedId, selectedClient, toggleVerified) + .verifyClient(user.qualifiedId, device, toggleVerified) .catch((error: DexieError) => logger.warn(`Failed to toggle client verification: ${error.message}`)); }; @@ -95,7 +95,7 @@ const DeviceDetails: React.FC = ({ setIsResettingSession(true); if (conversation) { messageRepository - .resetSession(user.qualifiedId, selectedClient.id, conversation) + .resetSession(user.qualifiedId, device.id, conversation) .then(_resetProgress) .catch(_resetProgress); } @@ -106,74 +106,84 @@ const DeviceDetails: React.FC = ({ return (
    - -

    - - {t('participantDevicesDetailHowTo')} - -

    - -
    - {fingerprintRemote && ( -
    - -
    - )} - -
    -
    - */} + +
    +

    {t('participantDevicesProteusDeviceVerification')}

    + +

    + - + + {t('participantDevicesDetailHowTo')} + +

    + + {fingerprintRemote && ( + <> +

    + {t('participantDevicesProteusKeyFingerprint')} +

    + +
    + +
    + + )} + +

    + {t('preferencesDeviceDetailsVerificationStatus')} +

    + +
    +
    + + + +
    -
    - - {!isConversationMLS && ( - - )} -
    +

    + {t('preferencesDeviceDetailsFingerprintNotMatch')} +

    + + {!isConversationMLS && ( + + )} + +
    ); }; - -export {DeviceDetails}; diff --git a/src/script/components/userDevices/DeviceList.tsx b/src/script/components/userDevices/DeviceList.tsx index 11797ace6c6..dce2baf5e77 100644 --- a/src/script/components/userDevices/DeviceList.tsx +++ b/src/script/components/userDevices/DeviceList.tsx @@ -21,6 +21,7 @@ import React from 'react'; import cx from 'classnames'; +import {WireIdentity} from 'src/script/E2EIdentity'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; @@ -33,12 +34,13 @@ import {getPrivacyWhyUrl} from '../../externalRoute'; interface DeviceListProps { clickOnDevice: (client: ClientEntity) => void; + getDeviceIdentity?: (deviceId: string) => WireIdentity | undefined; clients: ClientEntity[]; noPadding: boolean; user: User; } -const DeviceList: React.FC = ({user, noPadding, clients, clickOnDevice}) => { +const DeviceList: React.FC = ({user, getDeviceIdentity, noPadding, clients, clickOnDevice}) => { const {name: userName} = useKoSubscribableChildren(user, ['name']); return ( @@ -67,7 +69,12 @@ const DeviceList: React.FC = ({user, noPadding, clients, clickO })} data-uie-name="item-device" > - clickOnDevice(client)} showVerified showIcon /> + clickOnDevice(client)} + showIcon + />
  • ))} diff --git a/src/script/components/userDevices/SelfFingerprint.tsx b/src/script/components/userDevices/SelfFingerprint.tsx index 8b280295b51..b0cec4224c6 100644 --- a/src/script/components/userDevices/SelfFingerprint.tsx +++ b/src/script/components/userDevices/SelfFingerprint.tsx @@ -26,12 +26,13 @@ import {container} from 'tsyringe'; import {WebAppEvents} from '@wireapp/webapp-events'; import {t} from 'Util/LocalizerUtil'; +import {splitFingerprint} from 'Util/StringUtil'; import {DeviceCard} from './DeviceCard'; import {ClientState} from '../../client/ClientState'; import type {CryptographyRepository} from '../../cryptography/CryptographyRepository'; -import {DeviceId} from '../DeviceId'; +import {FormattedId} from '../../page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId'; interface SelfFingerprintProps { clientState?: ClientState; @@ -39,7 +40,7 @@ interface SelfFingerprintProps { noPadding: boolean; } -const SelfFingerprint: React.FC = ({ +export const SelfFingerprint: React.FC = ({ cryptographyRepository, noPadding, clientState = container.resolve(ClientState), @@ -55,8 +56,9 @@ const SelfFingerprint: React.FC = ({
    {currentClient && }
    - +
    +
    ); }; - -export {SelfFingerprint}; diff --git a/src/script/conversation/ConversationCellState.ts b/src/script/conversation/ConversationCellState.ts index 28d241e53ea..db2c3d118a4 100644 --- a/src/script/conversation/ConversationCellState.ts +++ b/src/script/conversation/ConversationCellState.ts @@ -30,6 +30,7 @@ import type {MemberMessage} from '../entity/message/MemberMessage'; import type {SystemMessage} from '../entity/message/SystemMessage'; import type {Text} from '../entity/message/Text'; import {ConversationError} from '../error/ConversationError'; +import {ClientEvent} from '../event/Client'; enum ACTIVITY_TYPE { CALL = 'ConversationCellState.ACTIVITY_TYPE.CALL', @@ -334,6 +335,10 @@ const _getStateUnreadMessage = { string = t('notificationSharedLocation'); } else if (messageEntity.hasAssetImage()) { string = t('notificationAssetAdd'); + } else if (messageEntity?.type === ClientEvent.CONVERSATION.E2EI_VERIFICATION) { + string = t('tooltipConversationAllDevicesVerified'); + } else if (messageEntity.isVerification()) { + string = t('tooltipConversationAllDevicesVerified'); } if (!!string) { @@ -388,6 +393,7 @@ export const generateCellState = ( _getStateUnreadMessage, _getStateUserName, ]; + const matchingState = states.find(state => state.match(conversationEntity)) || _getStateDefault; return { diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index 9d4e0d9f0d7..b4496011a1f 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -70,7 +70,6 @@ import { } from 'Util/StringUtil'; import {TIME_IN_MILLIS} from 'Util/TimeUtil'; import {isBackendError} from 'Util/TypePredicateUtil'; -import {supportsMLS} from 'Util/util'; import {createUuid} from 'Util/uuid'; import {ACCESS_STATE} from './AccessState'; @@ -97,7 +96,6 @@ import {ConversationStateHandler} from './ConversationStateHandler'; import {ConversationStatus} from './ConversationStatus'; import {ConversationVerificationState} from './ConversationVerificationState'; import {ProteusConversationVerificationStateHandler} from './ConversationVerificationStateHandler'; -import {registerMLSConversationVerificationStateHandler} from './ConversationVerificationStateHandler/MLS'; import {OnConversationVerificationStateChange} from './ConversationVerificationStateHandler/shared'; import {EventMapper} from './EventMapper'; import {MessageRepository} from './MessageRepository'; @@ -289,15 +287,6 @@ export class ConversationRepository { this.conversationState, ); - if (supportsMLS()) { - // we register a handler that will handle MLS conversations on its own - registerMLSConversationVerificationStateHandler( - this.onConversationVerificationStateChange, - this.conversationState, - this.core, - ); - } - this.isBlockingNotificationHandling = true; this.teamState.isTeam.subscribe(() => this.mapGuestStatusSelf()); @@ -2974,6 +2963,7 @@ export class ConversationRepository { case ClientEvent.CONVERSATION.VERIFICATION: case ClientEvent.CONVERSATION.VOICE_CHANNEL_ACTIVATE: case ClientEvent.CONVERSATION.VOICE_CHANNEL_DEACTIVATE: + case ClientEvent.CONVERSATION.E2EI_VERIFICATION: return this.addEventToConversation(conversationEntity, eventJson); } } diff --git a/src/script/conversation/ConversationState.ts b/src/script/conversation/ConversationState.ts index 1e0939a65ef..aed7930780a 100644 --- a/src/script/conversation/ConversationState.ts +++ b/src/script/conversation/ConversationState.ts @@ -55,8 +55,8 @@ export class ConversationState { public readonly visibleConversations: ko.PureComputed; public readonly filteredConversations: ko.PureComputed; public readonly archivedConversations: ko.PureComputed; - private readonly selfProteusConversation: ko.PureComputed; - private readonly selfMLSConversation: ko.PureComputed; + public readonly selfProteusConversation: ko.PureComputed; + public readonly selfMLSConversation: ko.PureComputed; public readonly unreadConversations: ko.PureComputed; /** * All the users that are connected to the selfUser through a conversation. Those users are not necessarily **directly** connected to the selfUser (through a connection request) @@ -78,7 +78,10 @@ export class ConversationState { this.conversations().find(conversation => !isMLSConversation(conversation) && isSelfConversation(conversation)), ); this.selfMLSConversation = ko.pureComputed(() => - this.conversations().find(conversation => isMLSConversation(conversation) && isSelfConversation(conversation)), + this.conversations().find( + (conversation): conversation is MLSConversation => + isMLSConversation(conversation) && isSelfConversation(conversation), + ), ); this.visibleConversations = ko.pureComputed(() => { @@ -151,7 +154,7 @@ export class ConversationState { return proteusConversation; } - getSelfMLSConversation(): Conversation { + getSelfMLSConversation(): MLSConversation { const mlsConversation = this.selfMLSConversation(); if (!mlsConversation) { throw new Error('No MLS self conversation'); diff --git a/src/script/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.test.ts b/src/script/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.test.ts index 23a82f48d3f..3e91dc8e5f0 100644 --- a/src/script/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.test.ts +++ b/src/script/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.test.ts @@ -18,191 +18,76 @@ */ import {ConversationProtocol} from '@wireapp/api-client/lib/conversation/NewConversation'; +import {E2eiConversationState} from '@wireapp/core/lib/messagingProtocols/mls'; -import {ClientEntity} from 'src/script/client'; +import * as e2eIdentity from 'src/script/E2EIdentity/E2EIdentityVerification'; import {Conversation} from 'src/script/entity/Conversation'; -import {User} from 'src/script/entity/User'; import {Core} from 'src/script/service/CoreSingleton'; import {createUuid} from 'Util/uuid'; -import { - MLSConversationVerificationStateHandler, - registerMLSConversationVerificationStateHandler, -} from './MLSStateHandler'; +import {registerMLSConversationVerificationStateHandler} from './MLSStateHandler'; import {ConversationState} from '../../ConversationState'; +import {ConversationVerificationState} from '../../ConversationVerificationState'; describe('MLSConversationVerificationStateHandler', () => { - const uuid = createUuid(); - let handler: MLSConversationVerificationStateHandler; - let mockOnConversationVerificationStateChange: jest.Mock; - let mockConversationState: jest.Mocked; - let mockCore: jest.Mocked; - const groupId = 'groupIdXYZ'; - const clientEntityId = 'clientIdXYZ'; - const selfClientEntityId = 'selfClientIdXYZ'; - const clientEntity: ClientEntity = new ClientEntity(false, '', clientEntityId); - const selfClientEntity: ClientEntity = new ClientEntity(false, '', selfClientEntityId); - const conversation: Conversation = new Conversation(uuid, '', ConversationProtocol.MLS); + const conversationState = new ConversationState(); + let core = new Core(); + const groupId = 'AAEAAKA0LuGtiU7NjqqlZIE2dQUAZWxuYS53aXJlLmxpbms='; + const conversation = new Conversation(createUuid(), '', ConversationProtocol.MLS); + conversationState.conversations.push(conversation); + conversation.groupId = groupId; beforeEach(() => { - conversation.groupId = groupId; - conversation.getAllUserEntities = jest.fn().mockReturnValue([ - { - devices: () => [clientEntity], - }, - ]); - mockOnConversationVerificationStateChange = jest.fn(); - // Mock the conversation state - mockConversationState = { - filteredConversations: () => [conversation], - } as unknown as jest.Mocked; - mockCore = { - service: { - mls: { - on: jest.fn(), - }, - e2eIdentity: { - getUserDeviceEntities: jest.fn().mockResolvedValue([ - { - certificate: 'mockCertificate', - clientId: clientEntityId, - }, - { - certificate: 'mockCertificate', - clientId: selfClientEntityId, - }, - ]), - }, - }, - } as unknown as jest.Mocked; - - handler = new MLSConversationVerificationStateHandler( - mockOnConversationVerificationStateChange, - mockConversationState, - mockCore, - ); - + core = new Core(); jest.clearAllMocks(); }); it('should do nothing if MLS service is not available', () => { - mockCore.service.mls = undefined; + core.service!.mls = undefined; - const t = () => - registerMLSConversationVerificationStateHandler( - mockOnConversationVerificationStateChange, - mockConversationState, - mockCore, - ); + const t = () => registerMLSConversationVerificationStateHandler(undefined, conversationState, core); expect(t).not.toThrow(); }); it('should do nothing if e2eIdentity service is not available', () => { - mockCore.service.e2eIdentity = undefined; - - registerMLSConversationVerificationStateHandler( - mockOnConversationVerificationStateChange, - mockConversationState, - mockCore, - ); - - // Assert - expect(mockCore.service?.mls?.on).not.toHaveBeenCalled(); - }); + core.service!.e2eIdentity = undefined; - it('should hook into the newEpoch event of the MLS service', () => { - registerMLSConversationVerificationStateHandler( - mockOnConversationVerificationStateChange, - mockConversationState, - mockCore, - ); + registerMLSConversationVerificationStateHandler(undefined, conversationState, core); - // Assert - expect(mockCore.service.mls.on).toHaveBeenCalledWith('newEpoch', expect.any(Function)); + expect(core.service?.mls?.on).not.toHaveBeenCalled(); }); - describe('checkEpoch', () => { - it('should degrade conversation if not all user entities have certificates', async () => { - jest.spyOn(handler as any, 'degradeConversation'); - - const mockData = { - groupId, - epoch: 12345, - }; - - jest.spyOn(handler as any, 'updateUserDevices').mockResolvedValue({ - isResultComplete: false, - identities: [], - qualifiedIds: [], - }); - - await (handler as any).checkEpoch(mockData); - - expect((handler as any).degradeConversation).toHaveBeenCalled(); - }); - - it('should verify conversation if all checks pass', async () => { - jest.spyOn(handler as any, 'verifyConversation'); - - const mockData = { - groupId, - epoch: 12345, - }; + describe('checkConversationVerificationState', () => { + it('should degrade conversation', async () => { + let triggerEpochChange: Function = () => {}; + conversation.mlsVerificationState(ConversationVerificationState.VERIFIED); + jest.spyOn(e2eIdentity, 'getConversationVerificationState').mockResolvedValue(E2eiConversationState.Degraded); + jest + .spyOn(core.service!.mls!, 'on') + .mockImplementation((_event, listener) => (triggerEpochChange = listener) as any); - jest.spyOn(handler as any, 'updateUserDevices').mockResolvedValue({ - isResultComplete: true, - identities: [ - { - certificate: 'mockCertificate', - }, - ], - qualifiedIds: [], - }); + registerMLSConversationVerificationStateHandler(undefined, conversationState, core); - jest.spyOn(handler as any, 'isCertificateActiveAndValid').mockResolvedValue(true); - - await (handler as any).checkEpoch(mockData); // Calling the private method - - expect((handler as any).verifyConversation).toHaveBeenCalled(); + triggerEpochChange({groupId}); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(conversation.mlsVerificationState()).toBe(ConversationVerificationState.DEGRADED); }); - it('should update ClientEntity isMLSVerified observable', async () => { - const mockData = { - groupId, - epoch: 12345, - }; - - jest.spyOn(handler as any, 'isCertificateActiveAndValid').mockReturnValue(true); - jest.spyOn(handler as any, 'verifyConversation').mockImplementation(() => null); - - expect(clientEntity.meta.isMLSVerified?.()).toBe(false); - - await (handler as any).checkEpoch(mockData); // Calling the private method - - expect(clientEntity.meta.isMLSVerified?.()).toBe(true); - }); - - it('should update selfClient isMLSVerified observable', async () => { - const mockData = { - groupId, - epoch: 12345, - }; - - const user = new User(); - user.isMe = true; - user.localClient = selfClientEntity; - conversation.getAllUserEntities = jest.fn().mockReturnValue([user]); - - jest.spyOn(handler as any, 'isCertificateActiveAndValid').mockReturnValue(true); - jest.spyOn(handler as any, 'verifyConversation').mockImplementation(() => null); - - expect(selfClientEntity.meta.isMLSVerified?.()).toBe(false); + it('should verify conversation', async () => { + let triggerEpochChange: Function = () => {}; + conversation.mlsVerificationState(ConversationVerificationState.DEGRADED); + jest.spyOn(e2eIdentity, 'getConversationVerificationState').mockResolvedValue(E2eiConversationState.Verified); + jest + .spyOn(core.service!.mls!, 'on') + .mockImplementation((_event, listener) => (triggerEpochChange = listener) as any); - await (handler as any).checkEpoch(mockData); // Calling the private method + registerMLSConversationVerificationStateHandler(undefined, conversationState, core); - expect(selfClientEntity.meta.isMLSVerified?.()).toBe(true); + triggerEpochChange({groupId}); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(conversation.mlsVerificationState()).toBe(ConversationVerificationState.VERIFIED); }); }); }); diff --git a/src/script/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.ts b/src/script/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.ts index a19b6203305..324d08c3820 100644 --- a/src/script/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.ts +++ b/src/script/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.ts @@ -17,32 +17,27 @@ * */ -import {X509Certificate} from '@peculiar/x509'; import {QualifiedId} from '@wireapp/api-client/lib/user'; -import {WireIdentity} from '@wireapp/core/lib/messagingProtocols/mls'; +import {E2eiConversationState} from '@wireapp/core/lib/messagingProtocols/mls'; import {container} from 'tsyringe'; -import {ClientEntity} from 'src/script/client'; +import {getConversationVerificationState, getUsersIdentities, MLSStatuses} from 'src/script/E2EIdentity'; import {VerificationMessageType} from 'src/script/message/VerificationMessageType'; import {Core} from 'src/script/service/CoreSingleton'; import {Logger, getLogger} from 'Util/Logger'; -import {MLSConversation, isMLSConversation} from '../../ConversationSelectors'; +import {MLSConversation} from '../../ConversationSelectors'; import {ConversationState} from '../../ConversationState'; -import { - getConversationByGroupId, - attemptChangeToDegraded, - attemptChangeToVerified, - OnConversationVerificationStateChange, -} from '../shared'; +import {ConversationVerificationState} from '../../ConversationVerificationState'; +import {getConversationByGroupId, OnConversationVerificationStateChange} from '../shared'; -export class MLSConversationVerificationStateHandler { +class MLSConversationVerificationStateHandler { private readonly logger: Logger; public constructor( private readonly onConversationVerificationStateChange: OnConversationVerificationStateChange, - private readonly conversationState = container.resolve(ConversationState), - private readonly core = container.resolve(Core), + private readonly conversationState: ConversationState, + private readonly core: Core, ) { this.logger = getLogger('MLSConversationVerificationStateHandler'); // We need to check if the core service is available and if the e2eIdentity is available @@ -51,129 +46,74 @@ export class MLSConversationVerificationStateHandler { } // We hook into the newEpoch event of the MLS service to check if the conversation needs to be verified or degraded - this.core.service.mls.on('newEpoch', this.checkEpoch); + this.core.service.mls.on('newEpoch', this.checkConversationVerificationState); } /** * This function checks if the conversation is verified and if it is, it will degrade it - * @param conversationEntity + * @param conversation * @param userIds */ - private degradeConversation = async (conversationEntity: MLSConversation, userIds: QualifiedId[]) => { - this.logger.log(`Conversation ${conversationEntity.name} will be degraded`); - const conversationVerificationState = attemptChangeToDegraded({ - conversationEntity, - logger: this.logger, - }); - if (conversationVerificationState) { - this.onConversationVerificationStateChange({ - conversationEntity, - conversationVerificationState, - verificationMessageType: VerificationMessageType.UNVERIFIED, - userIds, - }); + private async degradeConversation(conversation: MLSConversation) { + const state = ConversationVerificationState.DEGRADED; + conversation.mlsVerificationState(state); + const userIdentities = await getUsersIdentities(conversation.groupId, conversation.participating_user_ids()); + const degradedUsers: QualifiedId[] = []; + for (const [userId, identities] of userIdentities.entries()) { + if (identities.some(identity => identity.status !== MLSStatuses.VALID)) { + degradedUsers.push({id: userId, domain: ''}); + } } - }; + + this.onConversationVerificationStateChange({ + conversationEntity: conversation, + conversationVerificationState: state, + verificationMessageType: VerificationMessageType.UNVERIFIED, + userIds: degradedUsers, + }); + } /** * This function checks if the conversation is degraded and if it is, it will verify it - * @param conversationEntity + * @param conversation * @param userIds */ - private verifyConversation = async (conversationEntity: MLSConversation) => { - this.logger.log(`Conversation ${conversationEntity.name} will be verified`); - - const conversationVerificationState = attemptChangeToVerified({conversationEntity, logger: this.logger}); - - if (conversationVerificationState) { - this.onConversationVerificationStateChange({ - conversationEntity, - conversationVerificationState, - }); - } - }; - - /** - * This function returns the WireIdentity of all userDeviceEntities in a conversation, as long as they have a certificate. - * If the conversation has userDeviceEntities without a certificate, it will not be included in the returned array - * - * It also updates the isMLSVerified observable of all the devices in the conversation - */ - private updateUserDevices = async (conversation: MLSConversation) => { - const userEntities = conversation.getAllUserEntities(); - const allClients: ClientEntity[] = []; - const allIdentities: WireIdentity[] = []; - userEntities.forEach(async userEntity => { - let devices = userEntity.devices(); - // Add the localClient to the devices array if it is the current user - if (userEntity.isMe && userEntity.localClient) { - devices = [...devices, userEntity.localClient]; - } - const deviceUserPairs = devices - .map(device => ({ - [device.id]: userEntity.qualifiedId, - })) - .reduce((acc, current) => { - return {...acc, ...current}; - }, {}); - const identities = await this.core.service!.e2eIdentity!.getUserDeviceEntities( - conversation.groupId, - deviceUserPairs, - ); - identities.forEach(async identity => { - const verified = await this.isCertificateActiveAndValid(identity.certificate); - if (verified) { - const device = devices.find(device => device.id === identity.clientId); - /** - * ToDo: Change the current implementation of isMLSVerified to be stored in Zustand instead of ko.observable - */ - device?.meta.isMLSVerified?.(true); - allIdentities.push(identity); - } - }); - allClients.push(...devices); + private async verifyConversation(conversation: MLSConversation) { + const state = ConversationVerificationState.VERIFIED; + conversation.mlsVerificationState(state); + this.onConversationVerificationStateChange({ + conversationEntity: conversation, + conversationVerificationState: state, }); - - return { - isResultComplete: allClients.length === allIdentities.length, - qualifiedIds: userEntities.map(userEntity => userEntity.qualifiedId), - }; - }; - - private async isCertificateActiveAndValid(certificateString: string): Promise { - const cert = new X509Certificate(certificateString); - const isValid = await cert.verify(); - const isActive = cert.notAfter.getTime() > Date.now(); - - return isValid && isActive; } - private async checkEpoch({groupId, epoch}: {groupId: string; epoch: number}): Promise { - this.logger.log(`Epoch changed to ${epoch} for groupId ${groupId}`); - const conversationEntity = getConversationByGroupId({conversationState: this.conversationState, groupId}); - if (!conversationEntity) { + private checkConversationVerificationState = async ({groupId}: {groupId: string}): Promise => { + const conversation = getConversationByGroupId({conversationState: this.conversationState, groupId}); + if (!conversation) { this.logger.error(`Epoch changed but conversationEntity can't be found`); return; } - if (isMLSConversation(conversationEntity)) { - const {isResultComplete, qualifiedIds} = await this.updateUserDevices(conversationEntity); - - // If the number of userDevicePairs is not equal to the number of identities, our Conversation is not secure - if (!isResultComplete) { - return this.degradeConversation(conversationEntity, qualifiedIds); - } - - // If we reach this point, all checks have passed and we can set the conversation to verified - return this.verifyConversation(conversationEntity); + const verificationState = await getConversationVerificationState(groupId); + + if ( + verificationState === E2eiConversationState.Degraded && + conversation.mlsVerificationState() !== ConversationVerificationState.DEGRADED + ) { + return this.degradeConversation(conversation); + } else if ( + verificationState === E2eiConversationState.Verified && + conversation.mlsVerificationState() !== ConversationVerificationState.VERIFIED + ) { + return this.verifyConversation(conversation); } - } + }; } export const registerMLSConversationVerificationStateHandler = ( - onConversationVerificationStateChange: OnConversationVerificationStateChange, - conversationState?: ConversationState, - core?: Core, + onConversationVerificationStateChange: OnConversationVerificationStateChange = () => {}, + conversationState: ConversationState = container.resolve(ConversationState), + core: Core = container.resolve(Core), ): void => { new MLSConversationVerificationStateHandler(onConversationVerificationStateChange, conversationState, core); }; diff --git a/src/script/conversation/ConversationVerificationStateHandler/shared/conversation/index.ts b/src/script/conversation/ConversationVerificationStateHandler/shared/conversation/index.ts index c52ceb56ce6..74bfcce5127 100644 --- a/src/script/conversation/ConversationVerificationStateHandler/shared/conversation/index.ts +++ b/src/script/conversation/ConversationVerificationStateHandler/shared/conversation/index.ts @@ -66,10 +66,7 @@ export const getConversationByGroupId = ({ groupId, }: GetConversationByGroupIdParams): MLSConversation | undefined => { const conversation = conversationState.filteredConversations().find(conversation => conversation.groupId === groupId); - if (conversation && isMLSConversation(conversation)) { - return conversation; - } - return undefined; + return conversation && isMLSConversation(conversation) ? conversation : undefined; }; /** diff --git a/src/script/conversation/EventBuilder.ts b/src/script/conversation/EventBuilder.ts index b23e0215fbd..e263c4c4df7 100644 --- a/src/script/conversation/EventBuilder.ts +++ b/src/script/conversation/EventBuilder.ts @@ -244,8 +244,12 @@ export interface ErrorEvent id: string; } +// E2EI Verified Events +export type AllE2EIVerifiedEvent = ConversationEvent; + export type ClientConversationEvent = | AllVerifiedEvent + | AllE2EIVerifiedEvent | AssetAddEvent | ErrorEvent | CompositeMessageAddEvent @@ -317,6 +321,17 @@ export const EventBuilder = { }; }, + buildAllE2EIVerified(conversationEntity: Conversation): AllE2EIVerifiedEvent { + return { + ...buildQualifiedId(conversationEntity), + data: undefined, + from: '', + id: createUuid(), + time: conversationEntity.getNextIsoDate(), + type: ClientEvent.CONVERSATION.E2EI_VERIFICATION, + }; + }, + buildCallingTimeoutEvent( reason: AVS_REASON.NOONE_JOINED | AVS_REASON.EVERYONE_LEFT, conversation: Conversation, diff --git a/src/script/conversation/EventMapper.ts b/src/script/conversation/EventMapper.ts index 6da1759e472..d3027c051ea 100644 --- a/src/script/conversation/EventMapper.ts +++ b/src/script/conversation/EventMapper.ts @@ -50,6 +50,7 @@ import {CompositeMessage} from '../entity/message/CompositeMessage'; import {ContentMessage} from '../entity/message/ContentMessage'; import {DecryptErrorMessage} from '../entity/message/DecryptErrorMessage'; import {DeleteMessage} from '../entity/message/DeleteMessage'; +import {E2EIVerificationMessage} from '../entity/message/E2EIVerificationMessage'; import {FailedToAddUsersMessage} from '../entity/message/FailedToAddUsersMessage'; import {FederationStopMessage} from '../entity/message/FederationStopMessage'; import {FileAsset} from '../entity/message/FileAsset'; @@ -349,6 +350,11 @@ export class EventMapper { break; } + case ClientEvent.CONVERSATION.E2EI_VERIFICATION: { + messageEntity = this._mapEventE2EIVerificationMessage(); + break; + } + case ClientEvent.CONVERSATION.ONE2ONE_CREATION: { messageEntity = this._mapEvent1to1Creation(event); break; @@ -675,6 +681,13 @@ export class EventMapper { return new MLSConversationRecoveredMessage(); } + /** + * Maps JSON data of E2E Identity verification message event to message entity. + */ + private _mapEventE2EIVerificationMessage(): MissedMessage { + return new E2EIVerificationMessage(); + } + /** * Maps JSON data of `conversation.knock` message into message entity. */ diff --git a/src/script/entity/Conversation.ts b/src/script/entity/Conversation.ts index 4bf5a01244a..6fda146492a 100644 --- a/src/script/entity/Conversation.ts +++ b/src/script/entity/Conversation.ts @@ -31,7 +31,7 @@ import ko from 'knockout'; import {container} from 'tsyringe'; import {Cancelable, debounce} from 'underscore'; -import {Availability, LegalHoldStatus} from '@wireapp/protocol-messaging'; +import {LegalHoldStatus} from '@wireapp/protocol-messaging'; import {WebAppEvents} from '@wireapp/webapp-events'; import {useLegalHoldModalState} from 'Components/Modals/LegalHoldModal/LegalHoldModal.state'; @@ -44,6 +44,7 @@ import {CallMessage} from './message/CallMessage'; import type {ContentMessage} from './message/ContentMessage'; import type {Message} from './message/Message'; import {PingMessage} from './message/PingMessage'; +import {SystemMessage} from './message/SystemMessage'; import type {User} from './User'; import type {Call} from '../calling/Call'; @@ -57,6 +58,7 @@ import {ConversationStatus} from '../conversation/ConversationStatus'; import {ConversationVerificationState} from '../conversation/ConversationVerificationState'; import {NOTIFICATION_STATE} from '../conversation/NotificationSetting'; import {ConversationError} from '../error/ConversationError'; +import {ClientEvent} from '../event/Client'; import {isContentMessage, isDeleteMessage} from '../guards/Message'; import {StatusType} from '../message/StatusType'; import {ConversationRecord} from '../storage/record/ConversationRecord'; @@ -70,6 +72,7 @@ interface UnreadState { pings: PingMessage[]; selfMentions: ContentMessage[]; selfReplies: ContentMessage[]; + systemMessages: SystemMessage[]; } enum TIMESTAMP_TYPE { @@ -98,7 +101,6 @@ export class Conversation { public readonly accessCode: ko.Observable; public readonly accessState: ko.Observable; public readonly archivedTimestamp: ko.Observable; - public readonly availabilityOfUser: ko.PureComputed; public readonly call: ko.Observable; public readonly cleared_timestamp: ko.Observable; public readonly connection: ko.Observable; @@ -128,7 +130,6 @@ export class Conversation { public readonly is_loaded: ko.Observable; public readonly is_pending: ko.Observable; public readonly is_verified: ko.PureComputed; - public readonly isMLSVerified: ko.PureComputed; public readonly is1to1: ko.PureComputed; public readonly isActiveParticipant: ko.PureComputed; public readonly isClearable: ko.PureComputed; @@ -169,8 +170,8 @@ export class Conversation { public teamId: string; public readonly type: ko.Observable; public readonly unreadState: ko.PureComputed; - public readonly verification_state: ko.Observable; - public readonly mlsVerificationState: ko.Observable; + public readonly verification_state = ko.observable(ConversationVerificationState.UNVERIFIED); + public readonly mlsVerificationState = ko.observable(ConversationVerificationState.UNVERIFIED); public readonly withAllTeamMembers: ko.Observable; public readonly hasExternal: ko.PureComputed; public readonly hasFederatedUsers: ko.PureComputed; @@ -218,7 +219,6 @@ export class Conversation { this.hasCreationMessage = false; this.firstUserEntity = ko.pureComputed(() => this.participating_user_ets()[0]); - this.availabilityOfUser = ko.pureComputed(() => this.firstUserEntity()?.availability()); this.isGuest = ko.observable(false); @@ -281,8 +281,6 @@ export class Conversation { // E2EE conversation states this.archivedState = ko.observable(false).extend({notify: 'always'}); this.mutedState = ko.observable(NOTIFICATION_STATE.EVERYTHING); - this.verification_state = ko.observable(ConversationVerificationState.UNVERIFIED); - this.mlsVerificationState = ko.observable(ConversationVerificationState.UNVERIFIED); this.archivedTimestamp = ko.observable(0); this.cleared_timestamp = ko.observable(0); @@ -329,13 +327,6 @@ export class Conversation { return this.allUserEntities().every(userEntity => userEntity.is_verified()); }); - this.isMLSVerified = ko.pureComputed(() => { - if (!this.hasInitializedUsers()) { - return undefined; - } - - return this.allUserEntities().every(userEntity => userEntity.isMLSVerified()); - }); this.legalHoldStatus = ko.observable(LegalHoldStatus.DISABLED); @@ -445,8 +436,10 @@ export class Conversation { pings: [], selfMentions: [], selfReplies: [], + systemMessages: [], }; const messages = [...this.messages(), ...this.incomingMessages()]; + for (let index = messages.length - 1; index >= 0; index--) { const messageEntity = messages[index]; if (messageEntity.visible()) { @@ -465,7 +458,11 @@ export class Conversation { const isSelfQuoted = isMessage && this.selfUser() && (messageEntity as ContentMessage).isUserQuoted(this.selfUser().id); - if (isMissedCall || isPing || isMessage) { + const isMLSProtocol = this.protocol === ConversationProtocol.MLS; + const isE2EIVerification = + isMLSProtocol && messageEntity?.type === ClientEvent.CONVERSATION.E2EI_VERIFICATION; + + if (isMissedCall || isPing || isMessage || isE2EIVerification) { unreadState.allMessages.push(messageEntity as ContentMessage); } @@ -972,13 +969,6 @@ export class Conversation { return userEntities.filter(userEntity => !userEntity.is_verified()); } - getUsersWithUnverifiedMLSClients(): User[] { - const userEntities = this.selfUser() - ? this.participating_user_ets().concat(this.selfUser()) - : this.participating_user_ets(); - return userEntities.filter(userEntity => !userEntity.isMLSVerified()); - } - getAllUserEntities(): User[] { return this.participating_user_ets(); } diff --git a/src/script/entity/User/User.ts b/src/script/entity/User/User.ts index fcb103bd1e0..7749d1c8cb8 100644 --- a/src/script/entity/User/User.ts +++ b/src/script/entity/User/User.ts @@ -66,7 +66,6 @@ export class User { // Manual Proteus verification public readonly is_verified: ko.PureComputed; // MLS certificate verification - public readonly isMLSVerified: ko.PureComputed; public readonly isBlocked: ko.PureComputed; public readonly isCanceled: ko.PureComputed; public readonly isConnected: ko.PureComputed; @@ -209,16 +208,6 @@ export class User { } return this.devices().every(client_et => client_et.meta.isVerified?.()); }); - this.isMLSVerified = ko.pureComputed(() => { - if (this.devices().length === 0) { - if (!this.isMe) { - return false; - } - - return this.localClient?.meta.isMLSVerified?.() ?? false; - } - return this.devices().every(client_et => client_et.meta.isMLSVerified?.() ?? false); - }); this.isOnLegalHold = ko.pureComputed(() => { return this.devices().some(client_et => client_et.isLegalHold()); }); diff --git a/src/script/entity/message/E2EIVerificationMessage.ts b/src/script/entity/message/E2EIVerificationMessage.ts new file mode 100644 index 00000000000..7aefd6aac4e --- /dev/null +++ b/src/script/entity/message/E2EIVerificationMessage.ts @@ -0,0 +1,37 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {replaceLink, t} from 'Util/LocalizerUtil'; + +import {SystemMessage} from './SystemMessage'; + +import {Config} from '../../Config'; +import {SystemMessageType} from '../../message/SystemMessageType'; + +export class E2EIVerificationMessage extends SystemMessage { + constructor() { + super(); + this.system_message_type = SystemMessageType.E2EI_VERIFIED; + this.caption = t( + 'tooltipConversationAllE2EIVerified', + {}, + replaceLink(Config.getConfig().URL.SUPPORT.E2EI_VERIFICATION), + ); + } +} diff --git a/src/script/event/Client.ts b/src/script/event/Client.ts index 56b75cf3be3..f83c2b3a96f 100644 --- a/src/script/event/Client.ts +++ b/src/script/event/Client.ts @@ -50,6 +50,7 @@ export enum CONVERSATION { FEDERATION_STOP = 'conversation.federation-stop', VOICE_CHANNEL_ACTIVATE = 'conversation.voice-channel-activate', VOICE_CHANNEL_DEACTIVATE = 'conversation.voice-channel-deactivate', + E2EI_VERIFICATION = 'conversation.e2ei-verification', } export enum USER { diff --git a/src/script/event/EventTypeHandling.ts b/src/script/event/EventTypeHandling.ts index 1e39046acf1..ab3fd9e7bc8 100644 --- a/src/script/event/EventTypeHandling.ts +++ b/src/script/event/EventTypeHandling.ts @@ -65,5 +65,6 @@ export const EventTypeHandling = { ClientEvent.CONVERSATION.VERIFICATION, ClientEvent.CONVERSATION.VOICE_CHANNEL_ACTIVATE, ClientEvent.CONVERSATION.VOICE_CHANNEL_DEACTIVATE, + ClientEvent.CONVERSATION.E2EI_VERIFICATION, ], }; diff --git a/src/script/guards/Protocol.ts b/src/script/guards/Protocol.ts index 4d5bf739091..10b9751e7db 100644 --- a/src/script/guards/Protocol.ts +++ b/src/script/guards/Protocol.ts @@ -18,6 +18,9 @@ */ import {ConversationProtocol} from '@wireapp/api-client/lib/conversation/NewConversation'; +import {FeatureMLS, FeatureMLSE2EId} from '@wireapp/api-client/lib/team'; + +import {isObject} from './common'; export interface ProtocolOption { label: string; @@ -28,3 +31,12 @@ export const isProtocolOption = (option: any): option is ProtocolOption => { const protocols = Object.values(ConversationProtocol) as string[]; return typeof option?.value === 'string' && protocols.includes(option.value); }; + +const isFeatureWithConfig = (feature: unknown): feature is {config: {}} => + isObject(feature) && 'config' in feature && isObject(feature.config); + +export const hasE2EIVerificationExpiration = (feature: unknown): feature is FeatureMLSE2EId => + isFeatureWithConfig(feature) && 'verificationExpiration' in feature.config; + +export const hasMLSDefaultProtocol = (feature: unknown): feature is FeatureMLS => + isFeatureWithConfig(feature) && 'defaultProtocol' in feature.config; diff --git a/src/script/guards/common.ts b/src/script/guards/common.ts new file mode 100644 index 00000000000..37a4212a084 --- /dev/null +++ b/src/script/guards/common.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export const isObject = (value: unknown): value is {} => typeof value === 'object' && value !== null; diff --git a/src/script/hooks/useDeviceIdentities.ts b/src/script/hooks/useDeviceIdentities.ts new file mode 100644 index 00000000000..f3a3c8933cf --- /dev/null +++ b/src/script/hooks/useDeviceIdentities.ts @@ -0,0 +1,54 @@ +/* + * Wire + * Copyright (C) 2021 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useEffect, useState} from 'react'; + +import {QualifiedId} from '@wireapp/api-client/lib/user'; + +import {getUsersIdentities, isE2EIEnabled, MLSStatuses, WireIdentity} from '../E2EIdentity'; + +export const useUserIdentity = (userId: QualifiedId, groupId?: string) => { + const [deviceIdentities, setDeviceIdentities] = useState(); + + useEffect(() => { + if (isE2EIEnabled() && groupId) { + void (async () => { + const userIdentities = await getUsersIdentities(groupId, [userId]); + setDeviceIdentities(userIdentities.get(userId.id) ?? []); + })(); + } + }, [userId.id, groupId]); + + return { + deviceIdentities, + + status: !deviceIdentities + ? undefined + : deviceIdentities.length > 0 && deviceIdentities.every(identity => identity.status === MLSStatuses.VALID) + ? MLSStatuses.VALID + : MLSStatuses.NOT_DOWNLOADED, + + getDeviceIdentity: + deviceIdentities !== undefined + ? (deviceId: string) => { + return deviceIdentities.find(identity => identity.deviceId === deviceId); + } + : undefined, + }; +}; diff --git a/src/script/integration/ServiceEntity.ts b/src/script/integration/ServiceEntity.ts index 3fd0d2b0489..307d65017ad 100644 --- a/src/script/integration/ServiceEntity.ts +++ b/src/script/integration/ServiceEntity.ts @@ -36,9 +36,9 @@ export interface ServiceData { export class ServiceEntity { description: string; id: string; - mediumPictureResource: ko.Observable; + mediumPictureResource = ko.observable(); name: ko.Observable; - previewPictureResource: ko.Observable; + previewPictureResource = ko.observable(); providerId: string; providerName: ko.Observable; summary: string; @@ -56,8 +56,6 @@ export class ServiceEntity { this.summary = summary; this.tags = tags; - this.mediumPictureResource = ko.observable(); - this.previewPictureResource = ko.observable(); this.isService = true; } } diff --git a/src/script/main/app.ts b/src/script/main/app.ts index d8b0ab9d61f..823fd8588f1 100644 --- a/src/script/main/app.ts +++ b/src/script/main/app.ts @@ -57,6 +57,10 @@ import {ConnectionRepository} from '../connection/ConnectionRepository'; import {ConnectionService} from '../connection/ConnectionService'; import {ConversationRepository} from '../conversation/ConversationRepository'; import {ConversationService} from '../conversation/ConversationService'; +import {ConversationVerificationState} from '../conversation/ConversationVerificationState'; +import {registerMLSConversationVerificationStateHandler} from '../conversation/ConversationVerificationStateHandler'; +import {OnConversationVerificationStateChange} from '../conversation/ConversationVerificationStateHandler/shared'; +import {EventBuilder} from '../conversation/EventBuilder'; import {MessageRepository} from '../conversation/MessageRepository'; import {CryptographyRepository} from '../cryptography/CryptographyRepository'; import {User} from '../entity/User'; @@ -370,6 +374,10 @@ export class App { throw new ClientError(CLIENT_ERROR_TYPE.NO_VALID_CLIENT, 'Client has been deleted on backend'); } + if (supportsMLS()) { + registerMLSConversationVerificationStateHandler(this.updateConversationVerificationState); + } + this.core.on(CoreEvents.NEW_SESSION, ({userId, clientId}) => { const newClient = {class: ClientClassification.UNKNOWN, id: clientId}; userRepository.addClientToUser(userId, newClient, true); @@ -798,4 +806,18 @@ export class App { doRedirect(signOutReason); } + + private updateConversationVerificationState: OnConversationVerificationStateChange = async ({ + conversationEntity, + conversationVerificationState, + }) => { + switch (conversationVerificationState) { + case ConversationVerificationState.VERIFIED: + const allVerifiedEvent = EventBuilder.buildAllE2EIVerified(conversationEntity); + await this.repository.event.injectEvent(allVerifiedEvent); + break; + default: + break; + } + }; } diff --git a/src/script/message/SystemMessageType.ts b/src/script/message/SystemMessageType.ts index 2a2739ea22e..c6308b93e47 100644 --- a/src/script/message/SystemMessageType.ts +++ b/src/script/message/SystemMessageType.ts @@ -37,4 +37,5 @@ export enum SystemMessageType { MEMBER_LEAVE = 'leave', NORMAL = 'normal', MLS_CONVERSATION_RECOVERED = 'mls-conversation-recovered', + E2EI_VERIFIED = 'e2ei-verified', } diff --git a/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx b/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx index b2d24ee4b81..4d2ce09b613 100644 --- a/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx +++ b/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx @@ -30,6 +30,7 @@ import {AvailabilityState} from 'Components/AvailabilityState'; import {CallingCell} from 'Components/calling/CallingCell'; import {Icon} from 'Components/Icon'; import {LegalHoldDot} from 'Components/LegalHoldDot'; +import {UserVerificationBadges} from 'Components/VerificationBadge'; import {ListState} from 'src/script/page/useAppState'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; @@ -87,10 +88,9 @@ const Conversations: React.FC = ({ }) => { const { name: userName, - availability: userAvailability, isOnLegalHold, hasPendingLegalHold, - } = useKoSubscribableChildren(selfUser, ['hasPendingLegalHold', 'isOnLegalHold', 'name', 'availability']); + } = useKoSubscribableChildren(selfUser, ['hasPendingLegalHold', 'isOnLegalHold', 'name']); const {classifiedDomains} = useKoSubscribableChildren(teamState, ['classifiedDomains']); const {connectRequests} = useKoSubscribableChildren(userState, ['connectRequests']); const {activeConversation} = useKoSubscribableChildren(conversationState, ['activeConversation']); @@ -183,12 +183,9 @@ const Conversations: React.FC = ({ css={{...(showLegalHold && {gridColumn: '2/3'})}} onClick={event => AvailabilityContextMenu.show(event.nativeEvent, 'left-list-availability-menu')} > - + + + {showLegalHold && ( diff --git a/src/script/page/MainContent/MainContent.tsx b/src/script/page/MainContent/MainContent.tsx index 63d23c8938c..2405910a92d 100644 --- a/src/script/page/MainContent/MainContent.tsx +++ b/src/script/page/MainContent/MainContent.tsx @@ -38,7 +38,7 @@ import {Collection} from './panels/Collection'; import {AboutPreferences} from './panels/preferences/AboutPreferences'; import {AccountPreferences} from './panels/preferences/AccountPreferences'; import {AVPreferences} from './panels/preferences/AVPreferences'; -import {DevicesPreferences} from './panels/preferences/devices/DevicesPreferences'; +import {DevicesPreferences} from './panels/preferences/DevicesPreferences'; import {OptionPreferences} from './panels/preferences/OptionPreferences'; import {ClientState} from '../../client/ClientState'; diff --git a/src/script/page/MainContent/panels/preferences/AccountPreferences.tsx b/src/script/page/MainContent/panels/preferences/AccountPreferences.tsx index 9a69ea62fe5..326b2377960 100644 --- a/src/script/page/MainContent/panels/preferences/AccountPreferences.tsx +++ b/src/script/page/MainContent/panels/preferences/AccountPreferences.tsx @@ -25,6 +25,8 @@ import {Runtime} from '@wireapp/commons'; import {ErrorFallback} from 'Components/ErrorFallback'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; import {useEnrichedFields} from 'Components/panel/EnrichedFields'; +import {UserVerificationBadges} from 'Components/VerificationBadge'; +import {ConversationState} from 'src/script/conversation/ConversationState'; import {ContentState} from 'src/script/page/useAppState'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; @@ -68,6 +70,7 @@ interface AccountPreferencesProps { userRepository: UserRepository; selfUser: User; isActivatedAccount?: boolean; + conversationState?: ConversationState; } const logger = getLogger('AccountPreferences'); @@ -83,6 +86,7 @@ export const AccountPreferences = ({ isActivatedAccount = false, showDomain = false, teamState = container.resolve(TeamState), + conversationState = container.resolve(ConversationState), }: AccountPreferencesProps) => { const {isTeam, teamName} = useKoSubscribableChildren(teamState, ['isTeam', 'teamName']); const {name, email, availability, username, managedBy, phone} = useKoSubscribableChildren(selfUser, [ @@ -93,6 +97,7 @@ export const AccountPreferences = ({ 'managedBy', 'phone', ]); + const canEditProfile = managedBy === User.CONFIG.MANAGED_BY.WIRE; const isDesktop = Runtime.isDesktopApp(); const config = Config.getConfig(); @@ -129,26 +134,16 @@ export const AccountPreferences = ({ return ( -
    -

    - {name} -

    - -
    +
    +
    +

    + {name} +

    + + +
    + +
    @@ -157,7 +152,7 @@ export const AccountPreferences = ({ {isActivatedAccount && isTeam && } {isActivatedAccount && ( -
    +
    userRepository.changeAccentColor(id)} />
    )} diff --git a/src/script/page/MainContent/panels/preferences/devices/DevicesPreferences.test.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/DevicesPreference.test.tsx similarity index 63% rename from src/script/page/MainContent/panels/preferences/devices/DevicesPreferences.test.tsx rename to src/script/page/MainContent/panels/preferences/DevicesPreferences/DevicesPreference.test.tsx index f4e4700674a..d94d3044a6b 100644 --- a/src/script/page/MainContent/panels/preferences/devices/DevicesPreferences.test.tsx +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/DevicesPreference.test.tsx @@ -18,16 +18,21 @@ */ import {render, waitFor} from '@testing-library/react'; +import {CONVERSATION_TYPE, ConversationProtocol} from '@wireapp/api-client/lib/conversation'; +import {randomUUID} from 'crypto'; + +import {withTheme} from 'src/script/auth/util/test/TestUtil'; import {ClientEntity} from 'src/script/client/ClientEntity'; import {ClientState} from 'src/script/client/ClientState'; import {ConversationState} from 'src/script/conversation/ConversationState'; import {CryptographyRepository} from 'src/script/cryptography/CryptographyRepository'; +import {User} from 'src/script/entity/User'; import {createUuid} from 'Util/uuid'; -import {DevicesPreferences} from './DevicesPreferences'; +import {DevicesPreferences} from './DevicesPreference'; -import {User} from '../../../../../entity/User'; +import {Conversation} from '../../../../../entity/Conversation'; function createDevice(): ClientEntity { const device = new ClientEntity(true, '', createUuid()); @@ -36,9 +41,26 @@ function createDevice(): ClientEntity { return device; } +function createConversation(protocol?: ConversationProtocol, type?: CONVERSATION_TYPE) { + const conversation = new Conversation(randomUUID(), '', protocol); + if (protocol === ConversationProtocol.MLS) { + conversation.groupId = `groupid-${randomUUID()}`; + conversation.epoch = 0; + } + if (type) { + conversation.type(type); + } + return conversation; +} + describe('DevicesPreferences', () => { + const selfProteusConversation = createConversation(ConversationProtocol.PROTEUS, CONVERSATION_TYPE.SELF); + const selfMLSConversation = createConversation(ConversationProtocol.MLS, CONVERSATION_TYPE.SELF); + const regularConversation = createConversation(); + const selfUser = new User(createUuid()); selfUser.devices([createDevice(), createDevice()]); + const clientState = new ClientState(); clientState.currentClient = createDevice(); const defaultParams = { @@ -54,11 +76,13 @@ describe('DevicesPreferences', () => { selfUser, }; + defaultParams.conversationState.conversations([selfProteusConversation, selfMLSConversation, regularConversation]); + it('displays all devices', async () => { - const {getByText, getAllByText} = render(); + const {getByText, getAllByText} = render(withTheme()); await waitFor(() => getByText('preferencesDevicesCurrent')); expect(getByText('preferencesDevicesCurrent')).toBeDefined(); - expect(getAllByText('preferencesDevicesId')).toHaveLength(selfUser.devices().length + 1); + expect(getAllByText('preferencesDevicesId')).toHaveLength(selfUser.devices().length); }); }); diff --git a/src/script/page/MainContent/panels/preferences/devices/DevicesPreferences.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/DevicesPreference.tsx similarity index 53% rename from src/script/page/MainContent/panels/preferences/devices/DevicesPreferences.tsx rename to src/script/page/MainContent/panels/preferences/DevicesPreferences/DevicesPreference.tsx index ef3a5806661..9c54ee69054 100644 --- a/src/script/page/MainContent/panels/preferences/devices/DevicesPreferences.tsx +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/DevicesPreference.tsx @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2022 Wire Swiss GmbH + * Copyright (C) 2023 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,142 +20,62 @@ import React, {useEffect, useState} from 'react'; import {QualifiedId} from '@wireapp/api-client/lib/user'; -import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; import {container} from 'tsyringe'; -import {Icon} from 'Components/Icon'; -import {VerifiedIcon} from 'Components/VerifiedIcon'; import {ClientEntity} from 'src/script/client/ClientEntity'; import {CryptographyRepository} from 'src/script/cryptography/CryptographyRepository'; +import {Conversation} from 'src/script/entity/Conversation'; +import {User} from 'src/script/entity/User'; +import {useUserIdentity} from 'src/script/hooks/useDeviceIdentities'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; -import {handleKeyDown} from 'Util/KeyboardUtil'; import {t} from 'Util/LocalizerUtil'; import {DetailedDevice} from './components/DetailedDevice'; -import {FormattedId} from './components/FormattedId'; -import {DeviceDetailsPreferences} from './DeviceDetailsPreferences'; +import {Device} from './components/Device'; +import {DeviceDetailsPreferences} from './components/DeviceDetailsPreferences'; import {ClientState} from '../../../../../client/ClientState'; import {ConversationState} from '../../../../../conversation/ConversationState'; -import {Conversation} from '../../../../../entity/Conversation'; -import {User} from '../../../../../entity/User'; import {PreferencesPage} from '../components/PreferencesPage'; -interface DeviceProps { - device: ClientEntity; - isSSO: boolean; - onRemove: (device: ClientEntity) => void; - onSelect: (device: ClientEntity) => void; - deviceNumber: number; -} - -const Device = ({device, isSSO, onSelect, onRemove, deviceNumber}: DeviceProps) => { - const {isVerified} = useKoSubscribableChildren(device.meta, ['isVerified']); - const verifiedLabel = isVerified ? t('preferencesDevicesVerification') : t('preferencesDeviceNotVerified'); - const deviceAriaLabel = `${t('preferencesDevice')} ${deviceNumber}, ${device.getName()}, ${verifiedLabel}`; - const handleClick = (event: React.MouseEvent) => { - event.stopPropagation(); - onRemove(device); - }; - - const handleKeyPress = (event: React.KeyboardEvent) => { - event.stopPropagation(); - }; - - const onDeviceSelect = () => onSelect(device); - - return ( -
    handleKeyDown(event, onDeviceSelect)} - tabIndex={TabIndex.FOCUSABLE} - role="button" - > -
    -
    - -
    - -
    -
    - {device.getName()} -
    - -

    - {t('preferencesDevicesId')} - - - - -

    -
    -
    - -
    - {!device.isLegalHold() && ( - - )} - -
    -
    - ); -}; - interface DevicesPreferencesProps { clientState: ClientState; conversationState: ConversationState; cryptographyRepository: CryptographyRepository; + selfUser: User; removeDevice: (device: ClientEntity) => Promise; resetSession: (userId: QualifiedId, device: ClientEntity, conversation: Conversation) => Promise; - selfUser: User; verifyDevice: (userId: QualifiedId, device: ClientEntity, isVerified: boolean) => void; } -const DevicesPreferences = ({ +export const DevicesPreferences: React.FC = ({ clientState = container.resolve(ClientState), conversationState = container.resolve(ConversationState), cryptographyRepository, + selfUser, removeDevice, verifyDevice, resetSession, - selfUser, -}: DevicesPreferencesProps) => { +}) => { const [selectedDevice, setSelectedDevice] = useState(); + const [localFingerprint, setLocalFingerprint] = useState(''); const {devices} = useKoSubscribableChildren(selfUser, ['devices']); + const {getDeviceIdentity} = useUserIdentity(selfUser.qualifiedId, conversationState.selfMLSConversation()?.groupId); const currentClient = clientState.currentClient; + const isSSO = selfUser.isNoPasswordSSO; const getFingerprint = (device: ClientEntity) => cryptographyRepository.getRemoteFingerprint(selfUser.qualifiedId, device.id); - const [localFingerprint, setLocalFingerprint] = useState(''); useEffect(() => { - cryptographyRepository.getLocalFingerprint().then(setLocalFingerprint); + void cryptographyRepository.getLocalFingerprint().then(setLocalFingerprint); }, [cryptographyRepository]); if (selectedDevice) { return ( { @@ -175,7 +95,14 @@ const DevicesPreferences = ({
    {t('preferencesDevicesCurrent')} - {currentClient && } + {currentClient && ( + + )}

    @@ -191,6 +118,7 @@ const DevicesPreferences = ({ onSelect={setSelectedDevice} onRemove={removeDevice} deviceNumber={++index} + getDeviceIdentity={getDeviceIdentity} /> ))}

    {t('preferencesDevicesActiveDetail')}

    @@ -199,5 +127,3 @@ const DevicesPreferences = ({
    ); }; - -export {DevicesPreferences}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DetailedDevice.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DetailedDevice.tsx new file mode 100644 index 00000000000..a6509685f1f --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DetailedDevice.tsx @@ -0,0 +1,57 @@ +/* + * Wire + * Copyright (C) 2022 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import React from 'react'; + +import {DeviceVerificationBadges} from 'Components/VerificationBadge'; +import {ClientEntity} from 'src/script/client/ClientEntity'; +import {WireIdentity} from 'src/script/E2EIdentity'; + +import {MLSDeviceDetails} from './MLSDeviceDetails'; +import {ProteusDeviceDetails} from './ProteusDeviceDetails'; + +export interface DeviceProps { + device: ClientEntity; + fingerprint: string; + isCurrentDevice?: boolean; + getDeviceIdentity?: (deviceId: string) => WireIdentity | undefined; + isProteusVerified?: boolean; +} + +export const DetailedDevice: React.FC = ({ + device, + fingerprint, + isCurrentDevice, + getDeviceIdentity, + isProteusVerified, +}) => { + const getIdentity = getDeviceIdentity ? () => getDeviceIdentity(device.id) : undefined; + return ( + <> +

    + {device.model} + +

    + + {getDeviceIdentity && } + + + + ); +}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/Device/Device.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/Device/Device.tsx new file mode 100644 index 00000000000..31f5c740b9c --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/Device/Device.tsx @@ -0,0 +1,122 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {MouseEvent, KeyboardEvent} from 'react'; + +import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; + +import {Icon} from 'Components/Icon'; +import {DeviceVerificationBadges} from 'Components/VerificationBadge'; +import {WireIdentity} from 'src/script/E2EIdentity'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {handleKeyDown} from 'Util/KeyboardUtil'; +import {t} from 'Util/LocalizerUtil'; +import {splitFingerprint} from 'Util/StringUtil'; + +import {ClientEntity} from '../../../../../../../client'; +import {FormattedId} from '../FormattedId'; + +interface DeviceProps { + device: ClientEntity; + isSSO: boolean; + getDeviceIdentity?: (deviceId: string) => WireIdentity | undefined; + onRemove: (device: ClientEntity) => void; + onSelect: (device: ClientEntity, currentDeviceIdentity?: WireIdentity) => void; + deviceNumber: number; +} + +export const Device = ({device, isSSO, onSelect, onRemove, getDeviceIdentity, deviceNumber}: DeviceProps) => { + const {isVerified} = useKoSubscribableChildren(device.meta, ['isVerified']); + const verifiedLabel = isVerified ? t('preferencesDevicesVerification') : t('preferencesDeviceNotVerified'); + const deviceAriaLabel = `${t('preferencesDevice')} ${deviceNumber}, ${device.getName()}, ${verifiedLabel}`; + + const deviceIdentity = getDeviceIdentity?.(device.id); + + const handleClick = (event: MouseEvent) => { + event.stopPropagation(); + onRemove(device); + }; + + const handleKeyPress = (event: KeyboardEvent) => { + event.stopPropagation(); + }; + + const onDeviceSelect = () => onSelect(device); + + return ( +
    handleKeyDown(event, onDeviceSelect)} + tabIndex={TabIndex.FOCUSABLE} + role="button" + > +
    +
    + {device.getName()} + +
    + + {deviceIdentity && ( +

    + {t('preferencesMLSThumbprint')} + + + + +

    + )} + +

    + {t('preferencesDevicesId')} + + + + +

    +
    + +
    + {!device.isLegalHold() && ( + + )} + +
    +
    + ); +}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/Device/index.ts b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/Device/index.ts new file mode 100644 index 00000000000..f134bf2cc9a --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/Device/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './Device'; diff --git a/src/script/page/MainContent/panels/preferences/devices/DeviceDetailsPreferences.test.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/DeviceDetailsPreferences.test.tsx similarity index 95% rename from src/script/page/MainContent/panels/preferences/devices/DeviceDetailsPreferences.test.tsx rename to src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/DeviceDetailsPreferences.test.tsx index 153e662b0a5..d2824309f36 100644 --- a/src/script/page/MainContent/panels/preferences/devices/DeviceDetailsPreferences.test.tsx +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/DeviceDetailsPreferences.test.tsx @@ -32,10 +32,11 @@ describe('DeviceDetailsPreferences', () => { const defaultParams = { device, getFingerprint: jest.fn().mockResolvedValue('00000000'), + getCertificate: jest.fn().mockResolvedValue('00000000'), onClose: jest.fn(), onRemove: jest.fn(), onResetSession: jest.fn().mockResolvedValue(undefined), - onVerify: jest.fn((_, isVerified) => device.meta.isVerified(isVerified)), + onVerify: jest.fn((_, isVerified) => device.meta?.isVerified(isVerified)), }; it('shows device details', async () => { const {getByText, getAllByText} = render(withTheme()); diff --git a/src/script/page/MainContent/panels/preferences/devices/DeviceDetailsPreferences.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/DeviceDetailsPreferences.tsx similarity index 77% rename from src/script/page/MainContent/panels/preferences/devices/DeviceDetailsPreferences.tsx rename to src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/DeviceDetailsPreferences.tsx index 7f6ff5ca4d5..fa056133a12 100644 --- a/src/script/page/MainContent/panels/preferences/devices/DeviceDetailsPreferences.tsx +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/DeviceDetailsPreferences.tsx @@ -22,17 +22,18 @@ import React, {useEffect, useState} from 'react'; import {Button, ButtonVariant} from '@wireapp/react-ui-kit'; import {ClientEntity} from 'src/script/client/ClientEntity'; +import {WireIdentity} from 'src/script/E2EIdentity'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; -import {DetailedDevice} from './components/DetailedDevice'; - -import {Config} from '../../../../../Config'; -import {MotionDuration} from '../../../../../motion/MotionDuration'; +import {Config} from '../../../../../../../Config'; +import {MotionDuration} from '../../../../../../../motion/MotionDuration'; +import {DetailedDevice} from '../DetailedDevice'; interface DevicesPreferencesProps { device: ClientEntity; getFingerprint: (device: ClientEntity) => Promise; + getDeviceIdentity?: (deviceId: string) => WireIdentity | undefined; onClose: () => void; onRemove: (device: ClientEntity) => void; onResetSession: (device: ClientEntity) => Promise; @@ -45,9 +46,10 @@ enum SessionResetState { RESET = 'reset', } -const DeviceDetailsPreferences: React.FC = ({ +export const DeviceDetailsPreferences: React.FC = ({ device, getFingerprint, + getDeviceIdentity, onVerify, onRemove, onClose, @@ -66,7 +68,7 @@ const DeviceDetailsPreferences: React.FC = ({ }; useEffect(() => { - getFingerprint(device).then(setFingerprint); + void getFingerprint(device).then(setFingerprint); }, [device, getFingerprint]); return ( @@ -76,6 +78,7 @@ const DeviceDetailsPreferences: React.FC = ({ data-uie-name="preferences-devices-details" >

    {t('preferencesDeviceDetails')}

    +
    @@ -87,9 +90,14 @@ const DeviceDetailsPreferences: React.FC = ({ aria-label={t('accessibility.preferencesDeviceDetails.goBack')} /> - -
    + + +

    + {t('preferencesDeviceDetailsVerificationStatus')} +

    + +
    = ({
    -

    {t('preferencesDevicesFingerprintDetail', brandName)}

    +

    {t('preferencesDevicesFingerprintDetail', brandName)}

    -
    -
    -
    -
    +

    + {t('preferencesDevicesSessionDetail')} +

    -
    -

    {t('preferencesDevicesSessionDetail')}

    -
    +
    {resetState === SessionResetState.RESET && ( )} + {resetState === SessionResetState.ONGOING && (

    {t('preferencesDevicesSessionOngoing')}

    )} + {resetState === SessionResetState.CONFIRMATION && (

    {t('preferencesDevicesSessionConfirmation')} @@ -139,9 +146,12 @@ const DeviceDetailsPreferences: React.FC = ({

    +
    + {!device.isLegalHold() && (
    -

    {t('preferencesDevicesRemoveDetail')}

    +

    {t('preferencesDevicesRemoveDetail')}

    +
    ); }; - -export {DeviceDetailsPreferences}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/index.ts b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/index.ts new file mode 100644 index 00000000000..5d8b26a3ea6 --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/DeviceDetailsPreferences/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './DeviceDetailsPreferences'; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.styles.ts b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.styles.ts new file mode 100644 index 00000000000..fc56908a974 --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.styles.ts @@ -0,0 +1,98 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/serialize'; + +import {MLSStatuses} from 'src/script/E2EIdentity'; + +const MLSStatusColor = { + [MLSStatuses.VALID]: 'var(--green-500)', + [MLSStatuses.EXPIRED]: 'var(--red-500)', + [MLSStatuses.NOT_DOWNLOADED]: 'var(--red-500)', + [MLSStatuses.EXPIRES_SOON]: 'var(--green-500)', +}; + +type stylesProps = { + container: CSSObject; + title: CSSObject; + e2eiStatusContainer: CSSObject; + e2eiStatus: (MLSStatus?: MLSStatuses) => CSSObject; + serialNumberWrapper: CSSObject; + notAvailable: CSSObject; + serialNumber: CSSObject; + delimiter: (position: number) => CSSObject; + buttonsGroup: CSSObject; +}; + +export const styles: stylesProps = { + container: { + paddingLeft: '16px', + borderLeft: '4px solid var(--gray-40)', + marginTop: '12px', + }, + title: { + marginBottom: '6px', + }, + e2eiStatusContainer: { + display: 'flex', + alignItems: 'center', + marginBottom: '6px', + + '.conversation-badges': { + marginLeft: '4px', + }, + }, + e2eiStatus: (MLSStatus?: MLSStatuses) => ({ + color: MLSStatus ? MLSStatusColor[MLSStatus] : 'var(--green-500)', + }), + serialNumberWrapper: { + marginBlock: '6px', + }, + notAvailable: { + color: 'var(--gray-70)', + }, + serialNumber: { + fontSize: 'var(--font-size-medium)', + lineHeight: 'var(--line-height-sm)', + textTransform: 'uppercase', + width: '217px', + textAlign: 'justify', + }, + delimiter: position => ({ + marginInline: '2px', + + [`:nth-of-type(${position})`]: { + marginRight: 0, + + '&::after': { + content: '""', + display: 'block', + }, + }, + }), + buttonsGroup: { + display: 'flex', + alignItems: 'center', + gap: '6px', + + '> button': { + marginBottom: 0, + }, + }, +}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.test.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.test.tsx new file mode 100644 index 00000000000..0623d02f89c --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.test.tsx @@ -0,0 +1,63 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {render} from '@testing-library/react'; + +import {withTheme} from 'src/script/auth/util/test/TestUtil'; +import {MLSStatuses, WireIdentity} from 'src/script/E2EIdentity'; + +import {E2EICertificateDetails} from './E2EICertificateDetails'; + +describe('E2EICertificateDetails', () => { + const generateIdentity = (status: MLSStatuses): WireIdentity => ({ + status, + certificate: 'certificate', + clientId: '', + displayName: '', + domain: '', + handle: '', + thumbprint: '', + deviceId: '', + }); + + it('is e2ei identity not downloaded', async () => { + const {getByTestId} = render(withTheme()); + + const E2EIdentityStatus = getByTestId('e2ei-identity-status'); + expect(E2EIdentityStatus.getAttribute('data-uie-value')).toEqual(MLSStatuses.NOT_DOWNLOADED); + }); + + it('is e2ei identity expired', async () => { + const identity = generateIdentity(MLSStatuses.EXPIRED); + + const {getByTestId} = render(withTheme()); + + const E2EIdentityStatus = getByTestId('e2ei-identity-status'); + expect(E2EIdentityStatus.getAttribute('data-uie-value')).toEqual(MLSStatuses.EXPIRED); + }); + + it('is e2ei identity verified', async () => { + const identity = generateIdentity(MLSStatuses.VALID); + + const {getByTestId} = render(withTheme()); + + const E2EIdentityStatus = getByTestId('e2ei-identity-status'); + expect(E2EIdentityStatus.getAttribute('data-uie-value')).toEqual(MLSStatuses.VALID); + }); +}); diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.tsx new file mode 100644 index 00000000000..9fe5acb468f --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/E2EICertificateDetails.tsx @@ -0,0 +1,108 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useState} from 'react'; + +import {Button, ButtonVariant} from '@wireapp/react-ui-kit'; + +import {CertificateDetailsModal} from 'Components/Modals/CertificateDetailsModal'; +import {VerificationBadges} from 'Components/VerificationBadge'; +import {E2EIHandler, MLSStatuses, WireIdentity} from 'src/script/E2EIdentity'; +import {t} from 'Util/LocalizerUtil'; +import {getLogger} from 'Util/Logger'; + +import {styles} from './E2EICertificateDetails.styles'; + +const logger = getLogger('E2EICertificateDetails'); + +interface E2EICertificateDetailsProps { + identity?: WireIdentity; + isCurrentDevice?: boolean; +} + +export const E2EICertificateDetails = ({identity, isCurrentDevice}: E2EICertificateDetailsProps) => { + const [isCertificateDetailsModalOpen, setIsCertificateDetailsModalOpen] = useState(false); + + const certificateState = identity?.status ?? MLSStatuses.NOT_DOWNLOADED; + const isNotDownloaded = certificateState === MLSStatuses.NOT_DOWNLOADED; + const isValid = certificateState === MLSStatuses.VALID; + + const updateCertificate = (): null => { + // TODO: Waiting for update certificate implementation + return null; + }; + + const getCertificate = async () => { + try { + await E2EIHandler.getInstance().enroll(); + } catch (error) { + logger.error('Cannot get E2EI instance: ', error); + } + }; + + return ( +
    +
    {t('E2EI.certificateTitle')}
    + +
    +

    + {t('E2EI.status')} + {t(`E2EI.${certificateState}`)} +

    + + +
    + +
    + {!isNotDownloaded && ( + + )} + + {isCertificateDetailsModalOpen && identity?.certificate && ( + setIsCertificateDetailsModalOpen(false)} + /> + )} + + {isCurrentDevice && ( + <> + {isNotDownloaded && ( + + )} + + {identity?.certificate && !isValid && ( + + )} + + )} +
    +
    + ); +}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/index.ts b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/index.ts new file mode 100644 index 00000000000..0bfb91a67bc --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/E2EICertificateDetails/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './E2EICertificateDetails'; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId.styles.ts b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId.styles.ts new file mode 100644 index 00000000000..87e1449b7b8 --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId.styles.ts @@ -0,0 +1,39 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react'; + +export const devicePart = (smallPadding = false): CSSObject => ({ + display: 'inline-block', + marginRight: smallPadding ? '4px' : '12px', + textTransform: 'uppercase', + + ...(!smallPadding && { + width: '18px', + + '&:nth-of-type(8n)': { + marginRight: 0, + + '&::after': { + display: 'block', + content: "' '", + }, + }, + }), +}); diff --git a/src/script/page/MainContent/panels/preferences/devices/components/FormattedId.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId.tsx similarity index 69% rename from src/script/page/MainContent/panels/preferences/devices/components/FormattedId.tsx rename to src/script/page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId.tsx index 7deda20c757..a2955f57163 100644 --- a/src/script/page/MainContent/panels/preferences/devices/components/FormattedId.tsx +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/FormattedId.tsx @@ -17,20 +17,19 @@ * */ +import {devicePart} from './FormattedId.styles'; + interface FormattedIdProps { idSlices: string[]; + smallPadding?: boolean; } -export const FormattedId = ({idSlices}: FormattedIdProps) => ( +export const FormattedId = ({idSlices, smallPadding = false}: FormattedIdProps) => ( <> - {idSlices.map((slice, index) => { - const Component = index % 2 === 0 ? 'strong' : 'span'; - - return ( - - {slice} - - ); - })} + {idSlices.map((slice, index) => ( + + {slice} + + ))} ); diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/MLSDeviceDetails.styles.ts b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/MLSDeviceDetails.styles.ts new file mode 100644 index 00000000000..862ddc25476 --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/MLSDeviceDetails.styles.ts @@ -0,0 +1,45 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react'; + +interface Styles { + wrapper: CSSObject; +} +export const styles: Styles = { + wrapper: { + width: '100%', + + '.preferences-devices &': { + marginTop: '32px', + }, + + '.preferences-device-details &': { + borderBottom: '1px solid var(--gray-40)', + }, + + '.preferences-devices-header &': { + borderBottom: '1px solid var(--gray-40)', + }, + + '.participant-devices__header &': { + paddingBottom: '24px', + }, + }, +}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/MLSDeviceDetails.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/MLSDeviceDetails.tsx new file mode 100644 index 00000000000..52c9a24690a --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/MLSDeviceDetails.tsx @@ -0,0 +1,51 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {WireIdentity} from 'src/script/E2EIdentity'; +import {t} from 'Util/LocalizerUtil'; +import {splitFingerprint} from 'Util/StringUtil'; + +import {styles} from './MLSDeviceDetails.styles'; + +import {MLSPublicKeys} from '../../../../../../../client'; +import {E2EICertificateDetails} from '../E2EICertificateDetails'; +import {FormattedId} from '../FormattedId'; + +interface MLSDeviceDetailsProps { + isCurrentDevice?: boolean; + identity: WireIdentity | undefined; +} + +export const MLSDeviceDetails = ({isCurrentDevice, identity}: MLSDeviceDetailsProps) => { + return ( +
    +

    {t('mlsSignature', MLSPublicKeys.ED25519.toUpperCase())}

    + {identity?.thumbprint && ( + <> +

    {t('mlsThumbprint')}

    + +

    + +

    + + )} + +
    + ); +}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/index.ts b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/index.ts new file mode 100644 index 00000000000..de35a488807 --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/MLSDeviceDetails/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './MLSDeviceDetails'; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/ProteusDeviceDetails.tsx b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/ProteusDeviceDetails.tsx new file mode 100644 index 00000000000..38fb97500c6 --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/components/ProteusDeviceDetails.tsx @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {VerificationBadges} from 'Components/VerificationBadge'; +import {t} from 'Util/LocalizerUtil'; +import {splitFingerprint} from 'Util/StringUtil'; +import {formatTimestamp} from 'Util/TimeUtil'; + +import {type DeviceProps} from './DetailedDevice'; +import {FormattedId} from './FormattedId'; + +interface ProteusDeviceDetailsProps extends Omit { + isProteusVerified?: boolean; + showVerificationStatus?: boolean; +} + +export const ProteusDeviceDetails = ({device, fingerprint, isProteusVerified}: ProteusDeviceDetailsProps) => { + return ( +
    +

    {t('proteusDeviceDetails')}

    + +
    +

    {t('proteusID')}

    + +

    + +

    +
    + + {device.time !== undefined && ( +
    +

    + {t('preferencesDevicesActivatedOn')} +

    + +

    {formatTimestamp(device.time)}

    +
    + )} + +

    + {t('participantDevicesProteusKeyFingerprint')} +

    + +

    + +

    + + {isProteusVerified !== undefined && ( + <> +

    + {t('preferencesDeviceDetailsVerificationStatus')} +

    + +

    + {isProteusVerified ? ( + <> + {t('proteusVerified')} + + + ) : ( + {t('proteusNotVerified')} + )} +

    + + )} +
    + ); +}; diff --git a/src/script/page/MainContent/panels/preferences/DevicesPreferences/index.ts b/src/script/page/MainContent/panels/preferences/DevicesPreferences/index.ts new file mode 100644 index 00000000000..5e56f57b0a3 --- /dev/null +++ b/src/script/page/MainContent/panels/preferences/DevicesPreferences/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './DevicesPreference'; diff --git a/src/script/page/MainContent/panels/preferences/accountPreferences/AvailabilityButtons.tsx b/src/script/page/MainContent/panels/preferences/accountPreferences/AvailabilityButtons.tsx index 4ad1ec90290..754ba4615c2 100644 --- a/src/script/page/MainContent/panels/preferences/accountPreferences/AvailabilityButtons.tsx +++ b/src/script/page/MainContent/panels/preferences/accountPreferences/AvailabilityButtons.tsx @@ -48,6 +48,7 @@ const headerStyles: CSSObject = { lineHeight: '0.875rem', margin: '37px 0 6px', padding: 0, + textAlign: 'center', }; const AvailabilityButtons: React.FC = ({availability}) => { diff --git a/src/script/page/MainContent/panels/preferences/devices/components/DetailedDevice.tsx b/src/script/page/MainContent/panels/preferences/devices/components/DetailedDevice.tsx deleted file mode 100644 index 4c1e3801d0b..00000000000 --- a/src/script/page/MainContent/panels/preferences/devices/components/DetailedDevice.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Wire - * Copyright (C) 2022 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -import React from 'react'; - -import {ClientEntity} from 'src/script/client/ClientEntity'; -import {t} from 'Util/LocalizerUtil'; -import {splitFingerprint} from 'Util/StringUtil'; -import {formatTimestamp} from 'Util/TimeUtil'; - -import {FormattedId} from './FormattedId'; - -interface DeviceProps { - device: ClientEntity; - fingerprint: string; -} - -const DetailedDevice: React.FC = ({device, fingerprint}) => { - return ( - <> -

    - {device.model} -

    - -

    - {t('preferencesDevicesId')} - - - - -

    - - {device.time !== undefined && ( -
    -

    -

    - )} - -

    - {t('preferencesDevicesFingerprint')} -

    - -

    - -

    - - ); -}; - -export {DetailedDevice}; diff --git a/src/script/page/RightSidebar/ConversationDetails/ConversationDetails.tsx b/src/script/page/RightSidebar/ConversationDetails/ConversationDetails.tsx index ab4bd729287..8f7f4eae0f2 100644 --- a/src/script/page/RightSidebar/ConversationDetails/ConversationDetails.tsx +++ b/src/script/page/RightSidebar/ConversationDetails/ConversationDetails.tsx @@ -31,6 +31,7 @@ import {ServiceDetails} from 'Components/panel/ServiceDetails'; import {UserDetails} from 'Components/panel/UserDetails'; import {ServiceList} from 'Components/ServiceList/ServiceList'; import {UserSearchableList} from 'Components/UserSearchableList'; +import {UserVerificationBadges} from 'Components/VerificationBadge'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; import {sortUsersByPriority} from 'Util/StringUtil'; @@ -104,7 +105,6 @@ const ConversationDetails = forwardRef verification_state: verificationState, isGroup, removed_from_conversation: removedFromConversation, - display_name: displayName, notificationState, hasGlobalMessageTimer, globalMessageTimer, @@ -122,7 +122,6 @@ const ConversationDetails = forwardRef 'verification_state', 'isGroup', 'removed_from_conversation', - 'display_name', 'notificationState', 'hasGlobalMessageTimer', 'globalMessageTimer', @@ -154,11 +153,7 @@ const ConversationDetails = forwardRef 'team', ]); - const { - is_verified: isSelfVerified, - teamRole, - isActivatedAccount, - } = useKoSubscribableChildren(selfUser, ['is_verified', 'teamRole', 'isActivatedAccount']); + const {teamRole, isActivatedAccount} = useKoSubscribableChildren(selfUser, ['teamRole', 'isActivatedAccount']); const isActiveGroupParticipant = isGroup && !removedFromConversation; @@ -272,6 +267,10 @@ const ConversationDetails = forwardRef isTeam, ); + const renderParticipantsBadges = (participant: User) => { + return ; + }; + useEffect(() => { conversationRepository.refreshUnavailableParticipants(activeConversation); }, [activeConversation, conversationRepository]); @@ -310,9 +309,9 @@ const ConversationDetails = forwardRef {isSingleUserMode && !isServiceMode && firstParticipant && ( <> @@ -326,13 +325,12 @@ const ConversationDetails = forwardRef {showTopActions && showActionAddParticipants && ( @@ -362,6 +360,7 @@ const ConversationDetails = forwardRef {isGroup && !!userParticipants.length && ( <> ({ isActiveGroupParticipant: true, canRenameGroup: true, - displayName: 'Group Chat', updateConversationName: jest.fn(), - isGroup: true, userParticipants: new Array(participant), serviceParticipants: new Array(service), allUsersCount: 0, isTeam: false, + conversation, }); describe('ConversationDetailsHeader', () => { @@ -43,7 +50,7 @@ describe('ConversationDetailsHeader', () => { const props = getDefaultProps(); const {getByText} = render(); - const nameElement = getByText(props.displayName); + const nameElement = getByText(props.conversation.display_name()); expect(nameElement).not.toBe(null); }); @@ -51,7 +58,7 @@ describe('ConversationDetailsHeader', () => { const props = getDefaultProps(); const {getByText, getByTestId} = render(); - const nameElement = getByText(props.displayName); + const nameElement = getByText(props.conversation.display_name()); fireEvent.click(nameElement); const textareaElement = getByTestId('enter-name') as HTMLInputElement; @@ -62,7 +69,7 @@ describe('ConversationDetailsHeader', () => { const props = getDefaultProps(); const {getByText, getByTestId} = render(); - const nameElement = getByText(props.displayName); + const nameElement = getByText(props.conversation.display_name()); fireEvent.click(nameElement); const newGroupName = 'Group Name Update'; diff --git a/src/script/page/RightSidebar/ConversationDetails/components/ConversationDetailsHeader/ConversationDetailsHeader.tsx b/src/script/page/RightSidebar/ConversationDetails/components/ConversationDetailsHeader/ConversationDetailsHeader.tsx index 66e8809114b..e52a77030b7 100644 --- a/src/script/page/RightSidebar/ConversationDetails/components/ConversationDetailsHeader/ConversationDetailsHeader.tsx +++ b/src/script/page/RightSidebar/ConversationDetails/components/ConversationDetailsHeader/ConversationDetailsHeader.tsx @@ -20,11 +20,14 @@ import {ChangeEvent, FC, KeyboardEvent, useEffect, useRef, useState} from 'react'; import {Icon} from 'Components/Icon'; +import {ConversationVerificationBadges} from 'Components/VerificationBadge'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {isEnterKey} from 'Util/KeyboardUtil'; import {t} from 'Util/LocalizerUtil'; import {removeLineBreaks} from 'Util/StringUtil'; import {ConversationRepository} from '../../../../../conversation/ConversationRepository'; +import {Conversation} from '../../../../../entity/Conversation'; import {User} from '../../../../../entity/User'; import {ServiceEntity} from '../../../../../integration/ServiceEntity'; import {GroupDetails} from '../GroupDetails/GroupDetails'; @@ -32,26 +35,26 @@ import {GroupDetails} from '../GroupDetails/GroupDetails'; interface ConversationDetailsHeaderProps { isActiveGroupParticipant: boolean; canRenameGroup: boolean; - displayName: string; updateConversationName: (conversationName: string) => void; - isGroup: boolean; userParticipants: User[]; serviceParticipants: ServiceEntity[]; allUsersCount: number; isTeam?: boolean; + conversation: Conversation; } const ConversationDetailsHeader: FC = ({ isActiveGroupParticipant, canRenameGroup, - displayName, updateConversationName, - isGroup, userParticipants, serviceParticipants, allUsersCount, isTeam = false, + conversation, }) => { + const {isGroup, display_name: displayName} = useKoSubscribableChildren(conversation, ['isGroup', 'display_name']); + const textAreaRef = useRef(null); const isEditGroupNameTouched = useRef(false); @@ -146,6 +149,8 @@ const ConversationDetailsHeader: FC = ({ data-uie-name="enter-name" /> )} + + ) : (
    diff --git a/src/script/page/RightSidebar/ConversationParticipants/ConversationParticipants.tsx b/src/script/page/RightSidebar/ConversationParticipants/ConversationParticipants.tsx index 65ded3f7d2b..581af6df9d0 100644 --- a/src/script/page/RightSidebar/ConversationParticipants/ConversationParticipants.tsx +++ b/src/script/page/RightSidebar/ConversationParticipants/ConversationParticipants.tsx @@ -37,6 +37,7 @@ import {PanelEntity, PanelState} from '../RightSidebar'; interface ConversationParticipantsProps { activeConversation: Conversation; + renderParticipantBadges?: (user: User) => React.ReactNode; conversationRepository: ConversationRepository; searchRepository: SearchRepository; teamRepository: TeamRepository; @@ -55,6 +56,7 @@ const ConversationParticipants: FC = ({ onClose, onBack, highlightedUsers, + renderParticipantBadges, }) => { const [searchInput, setSearchInput] = useState(''); @@ -102,6 +104,7 @@ const ConversationParticipants: FC = ({ = ({ 'isTeam', 'team', ]); - const {is_verified: isSelfVerified, isActivatedAccount} = useKoSubscribableChildren(selfUser, [ - 'is_verified', - 'isActivatedAccount', - ]); + const {isActivatedAccount} = useKoSubscribableChildren(selfUser, ['isActivatedAccount']); const canChangeRole = conversationRoleRepository.canChangeParticipantRoles(activeConversation) && !currentUser.isMe && !isTemporaryGuest; @@ -138,6 +136,10 @@ const GroupParticipantUser: FC = ({ } }, [isTemporaryGuest, currentUser]); + const renderParticipantBadges = (participant: User) => { + return ; + }; + return (
    = ({ diff --git a/src/script/page/RightSidebar/ParticipantDevices/ParticipantDevices.tsx b/src/script/page/RightSidebar/ParticipantDevices/ParticipantDevices.tsx index 221e1925c36..a7623fbb8c8 100644 --- a/src/script/page/RightSidebar/ParticipantDevices/ParticipantDevices.tsx +++ b/src/script/page/RightSidebar/ParticipantDevices/ParticipantDevices.tsx @@ -30,11 +30,12 @@ import {PanelHeader} from '../PanelHeader'; interface ParticipantDevicesProps { onClose: () => void; onGoBack: (userEntity: User) => void; + groupId?: string; repositories: ViewModelRepositories; user: User; } -const ParticipantDevices: FC = ({repositories, onClose, onGoBack, user}) => { +const ParticipantDevices: FC = ({repositories, onClose, onGoBack, groupId, user}) => { const history = useUserDevicesHistory(); return ( @@ -57,6 +58,7 @@ const ParticipantDevices: FC = ({repositories, onClose, = ({ {currentState === PanelState.PARTICIPANT_DEVICES && userEntity && ( { const e2eiConfig = config[FEATURE_KEY.MLSE2EID]; diff --git a/src/script/service/CoreSingleton.ts b/src/script/service/CoreSingleton.ts index 5646ed2e53e..a0bbe84a2aa 100644 --- a/src/script/service/CoreSingleton.ts +++ b/src/script/service/CoreSingleton.ts @@ -27,6 +27,7 @@ import {APIClient} from './APIClientSingleton'; import {createStorageEngine, DatabaseTypes} from './StoreEngineProvider'; import {Config} from '../Config'; +import {isE2EIEnabled} from '../E2EIdentity'; declare global { interface Window { @@ -57,7 +58,7 @@ export class Core extends Account { ? { keyingMaterialUpdateThreshold: Config.getConfig().FEATURE.MLS_CONFIG_KEYING_MATERIAL_UPDATE_THRESHOLD, defaultCiphersuite: Config.getConfig().FEATURE.MLS_CONFIG_DEFAULT_CIPHERSUITE, - useE2EI: Config.getConfig().FEATURE.ENABLE_E2EI, + useE2EI: isE2EIEnabled(), } : undefined, diff --git a/src/script/storage/record/ClientRecord.ts b/src/script/storage/record/ClientRecord.ts index 58c4ce06475..127513332d4 100644 --- a/src/script/storage/record/ClientRecord.ts +++ b/src/script/storage/record/ClientRecord.ts @@ -34,4 +34,5 @@ export interface ClientRecord { model?: string; time?: string; type?: 'permanent' | 'temporary'; + mls_public_keys?: Record; } diff --git a/src/script/util/DebugUtil.ts b/src/script/util/DebugUtil.ts index e1c9301a5a7..07e20579acc 100644 --- a/src/script/util/DebugUtil.ts +++ b/src/script/util/DebugUtil.ts @@ -186,7 +186,7 @@ export class DebugUtil { } async reconnectWebSocketWithLastNotificationIdFromBackend({dryRun} = {dryRun: false}) { - await this.core.service?.notification.initializeNotificationStream(); + await this.core.service?.notification.initializeNotificationStream(this.clientState.currentClient!.id); return this.reconnectWebSocket({dryRun}); } diff --git a/src/script/util/TypePredicateUtil.ts b/src/script/util/TypePredicateUtil.ts index 9608428a662..19bb1c05754 100644 --- a/src/script/util/TypePredicateUtil.ts +++ b/src/script/util/TypePredicateUtil.ts @@ -17,11 +17,13 @@ * */ -import type {BackendError} from '@wireapp/api-client/lib/http/'; +import {RegisteredClient} from '@wireapp/api-client/lib/client'; +import type {BackendError} from '@wireapp/api-client/lib/http'; import {AxiosError} from 'axios'; import {Conversation} from '../entity/Conversation'; import {User} from '../entity/User'; +import {isObject} from '../guards/common'; import {ClientRecord} from '../storage/record/ClientRecord'; export function isAxiosError(errorCandidate: any): errorCandidate is AxiosError { @@ -43,3 +45,7 @@ export function isConversationEntity(conversation: any): conversation is Convers export function isClientRecord(record: any): record is ClientRecord { return !!record.meta; } + +export function isClientWithMLSPublicKeys(record: unknown): record is RegisteredClient { + return isObject(record) && 'mls_public_keys' in record; +} diff --git a/src/script/util/util.ts b/src/script/util/util.ts index 7260586d4c6..117d11e02d9 100644 --- a/src/script/util/util.ts +++ b/src/script/util/util.ts @@ -208,7 +208,7 @@ export const downloadBlob = (blob: Blob, filename: string, mimeType?: string): n throw new Error('Failed to download blob: Resource not provided'); }; -const downloadFile = (url: string, fileName: string, mimeType?: string): number => { +export const downloadFile = (url: string, fileName: string, mimeType?: string): number => { const anchor = document.createElement('a'); anchor.download = fileName; anchor.href = url; diff --git a/src/style/common/modal.less b/src/style/common/modal.less index 545121da075..20f74cd66be 100644 --- a/src/style/common/modal.less +++ b/src/style/common/modal.less @@ -271,7 +271,7 @@ margin-bottom: 16px; line-height: @line-height-sm; - & div:nth-child(even) { + & div:nth-of-type(even) { margin-bottom: 16px; } } diff --git a/src/style/common/typing.less b/src/style/common/typing.less index ba53f3c862d..92ad5d1fcf8 100644 --- a/src/style/common/typing.less +++ b/src/style/common/typing.less @@ -144,3 +144,32 @@ .focus-border-radius; } } + +// NEW Typography +.paragraph-body-1 { + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + letter-spacing: 0.05px; + line-height: var(--line-height-lg); +} + +.paragraph-body-3 { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.05px; + line-height: var(--line-height-lg); +} + +.label-1 { + font-size: var(--font-size-medium); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-sm); +} + +.label-2 { + color: var(--gray-70); + font-size: var(--font-size-small); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.25px; + line-height: var(--line-height-md); +} diff --git a/src/style/components/availability-state.less b/src/style/components/availability-state.less index 474711da7fe..e25f58600b1 100644 --- a/src/style/components/availability-state.less +++ b/src/style/components/availability-state.less @@ -20,4 +20,8 @@ .availability-state { display: flex; align-items: center; + + .conversation-badges { + margin-left: 4px; + } } diff --git a/src/style/components/device-card.less b/src/style/components/device-card.less index a7868e711ed..24455731e41 100644 --- a/src/style/components/device-card.less +++ b/src/style/components/device-card.less @@ -37,11 +37,11 @@ device-card, .disclose-icon { display: flex; - height: 8px; + height: 16px; align-self: center; path { - fill: var(--background-fade-40); + fill: var(--gray-90); } } @@ -51,9 +51,20 @@ device-card, line-height: @line-height-sm; } + &__name { + display: flex; + align-items: center; + + .conversation-badges { + margin-left: 4px; + } + } + &__label, &__model { + font-size: var(--font-size-medium); font-weight: @font-weight-bold; + line-height: var(--line-height-md); } .verified-icon, @@ -74,17 +85,18 @@ device-card, min-height: 16px; } -.device-id-part { - .text-selection; - - display: inline-block; - margin-right: 4px; +.device-card__id { + display: grid; + font-size: var(--font-size-small); + grid-template-columns: auto 1fr; + line-height: var(--line-height-sm); + text-transform: uppercase; - &:nth-child(odd) strong { - font-weight: @font-weight-bold; - } + .formatted-id { + overflow: hidden; - &:last-child { - margin-right: 0; + margin-left: 2px; + text-overflow: ellipsis; + white-space: nowrap; } } diff --git a/src/style/components/search-list.less b/src/style/components/search-list.less index cff7d5ee366..c6ebd8a8d84 100644 --- a/src/style/components/search-list.less +++ b/src/style/components/search-list.less @@ -40,7 +40,7 @@ .search-list-sm & { text-align: center; - &:nth-child(3n) { + &:nth-of-type(3n) { margin-right: 0; } } diff --git a/src/style/content/conversation/message-list.less b/src/style/content/conversation/message-list.less index 6056ac83525..4bd6668dd25 100644 --- a/src/style/content/conversation/message-list.less +++ b/src/style/content/conversation/message-list.less @@ -156,7 +156,7 @@ &--svg { line-height: 0; - svg path { + svg:not(.filled) path { fill: var(--foreground); } } @@ -873,3 +873,11 @@ background-color: var(--message-actions-background-hover); } } + +.system-message-caption { + & > a { + color: inherit; + font-weight: @font-weight-bold; + text-decoration: underline; + } +} diff --git a/src/style/content/conversation/title-bar.less b/src/style/content/conversation/title-bar.less index e697ae59f48..9e9668dc4de 100644 --- a/src/style/content/conversation/title-bar.less +++ b/src/style/content/conversation/title-bar.less @@ -125,6 +125,10 @@ body.theme-dark { .flex-center; display: flex; } + + + .conversation-badges { + margin-left: 4px; + } } .conversation-title-bar-name--subtitle { diff --git a/src/style/content/preferences.less b/src/style/content/preferences.less index 3273dbb6a44..26317181936 100644 --- a/src/style/content/preferences.less +++ b/src/style/content/preferences.less @@ -197,6 +197,14 @@ body.theme-dark { border: none; margin: 0; margin-bottom: 32px; + + .preferences-device-details & { + margin: 0; + } +} + +.preferences-reset-session { + margin-top: 12px; } .preferences-separator { @@ -215,3 +223,11 @@ body.theme-dark { .preferences-history-restore-button { margin-top: 20px; } + +.preferences-wrapper { + width: var(--preferences-width); + + .buttons-group { + justify-content: center; + } +} diff --git a/src/style/content/preferences/account.less b/src/style/content/preferences/account.less index a5b23ea0532..7803cd36244 100644 --- a/src/style/content/preferences/account.less +++ b/src/style/content/preferences/account.less @@ -212,7 +212,14 @@ } .preferences-account-name { - font-weight: @font-weight-bold; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + + .conversation-badges { + margin-left: 4px; + } } .preferences-account-username-atsign { @@ -232,3 +239,11 @@ cursor: pointer; } + +.preferences-account-image { + text-align: center; +} + +.preferences-accent-color-picker { + text-align: center; +} diff --git a/src/style/content/preferences/devices.less b/src/style/content/preferences/devices.less index 56e486ce97a..fd5d6c3619b 100644 --- a/src/style/content/preferences/devices.less +++ b/src/style/content/preferences/devices.less @@ -18,26 +18,46 @@ */ .preferences-devices-model { - font-size: @font-size-base; - font-weight: @font-weight-medium; - line-height: @line-height-lg; + display: flex; + align-items: center; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-lg); + + .conversation-badges { + margin-left: 4px; + } +} + +.preferences-devices-model-name { + display: flex; + align-items: center; + + font-size: var(--font-size-large); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-lg); + + .conversation-badges { + margin-left: 4px; + } } .preferences-devices-activated { margin: 16px 0; color: var(--gray-90); - font-size: @font-size-xs; - line-height: @line-height-sm; + font-size: var(--font-size-xs); + line-height: var(--line-height-sm); } .preferences-devices-card { display: flex; width: var(--preferences-width); - height: 72px; align-items: center; justify-content: space-between; - padding-top: 8px; + + border-bottom: 1px solid @separator-color; cursor: pointer; + padding-block: 4px 8px; .focus-default; @@ -47,29 +67,23 @@ align-items: center; justify-content: space-between; fill: var(--background); + &__delete { .button-reset-default; padding: 8px; .focus-default; } + &__forward { margin-left: auto; .button-reset-default; .focus-default; } } - - .preferences-devices-card-data { - display: flex; - } - - .preferences-devices-card-icon { - margin: 8px 16px 0 0; - } } -.preferences-devices-card + .preferences-devices-card { - border-top: 1px solid @separator-color; +.preferences-devices-card-info { + max-width: 372px; } .preferences-devices-details { @@ -77,7 +91,7 @@ display: flex; margin: 0 0 16px -34px; - line-height: @line-height-sm; + line-height: var(--line-height-sm); } .preferences-devices-icon { @@ -92,37 +106,41 @@ } .preferences-devices-id { - font-size: @font-size-small; - line-height: @line-height-xs; + display: grid; + font-size: var(--font-size-small); + grid-template-columns: auto 1fr; + line-height: var(--line-height-sm); text-transform: uppercase; - span:first-child { - .label-bold; + .preferences-formatted-id { + overflow: hidden; + + margin-left: 2px; + text-overflow: ellipsis; + white-space: nowrap; } } .preferences-devices-activated-bold { - font-weight: @font-weight-bold; + font-weight: var(--font-weight-bold); } .preferences-devices-fingerprint { - height: 48px; - font-family: monospace; - line-height: @line-height-lg; + max-width: 300px; + line-height: var(--line-height-lg); +} - .device-id-part { - display: inline; - } +.preferences-devices-verification-details { + display: flex; + align-items: center; - .device-id-part:nth-child(17)::before { - display: block; - content: ' '; + .conversation-badges { + margin-left: 4px; } } .preferences-devices-fingerprint-label { - margin-top: 32px; - margin-bottom: 8px; + margin-top: 12px; } .preferences-devices-session { @@ -133,8 +151,8 @@ .preferences-devices-session-reset { height: 16px; padding-top: 16px; - font-size: @font-size-xs; - line-height: @line-height-sm; + font-size: var(--font-size-xsmall); + line-height: var(--line-height-sm); } .preferences-devices-session-reset { @@ -155,3 +173,7 @@ body.theme-dark { color: var(--gray-20); } } + +.preferences-proteus-details { + margin-top: 32px; +} diff --git a/src/style/list/start-ui.less b/src/style/list/start-ui.less index 016bf101fe0..378d089dc9c 100644 --- a/src/style/list/start-ui.less +++ b/src/style/list/start-ui.less @@ -188,7 +188,7 @@ body.theme-dark { .search-list-item { margin-right: 20px; - &:nth-child(3n) { + &:nth-of-type(3n) { margin-right: 0; } &:nth-last-child(-n + 3) { diff --git a/src/style/panel/conversation-details.less b/src/style/panel/conversation-details.less index 3d43775fccb..1c9244204cb 100644 --- a/src/style/panel/conversation-details.less +++ b/src/style/panel/conversation-details.less @@ -32,6 +32,10 @@ &__header { padding: 0 16px; + + .conversation-badges { + margin-top: 6px; + } } &__flex-row { diff --git a/src/style/panel/panel.less b/src/style/panel/panel.less index ba57d73846e..e80125396f2 100644 --- a/src/style/panel/panel.less +++ b/src/style/panel/panel.less @@ -136,15 +136,16 @@ } &__info-text { - .subline; + > .participant-devices__link { + display: block; + } + &:focus-visible { .focus-outline; .focus-offset; .focus-border-radius; } - color: var(--gray-90); - body.theme-dark & { color: var(--gray-50); } diff --git a/src/style/panel/participant-devices.less b/src/style/panel/participant-devices.less index 6fdcdb2387b..89de5e043a1 100644 --- a/src/style/panel/participant-devices.less +++ b/src/style/panel/participant-devices.less @@ -19,28 +19,26 @@ .participant-devices { &__fingerprint { - min-height: 72px; + width: 230px; margin-bottom: 32px; - font-family: monospace; - line-height: 1.375rem; } &__header { &--padding { padding: 16px; } - & > * + * { - margin-top: 8px; - } } &__link { - margin: 8px 0; - color: @w-blue; + color: var(--black); cursor: pointer; - font-size: @font-size-xsmall; - font-weight: @font-weight-bold; - text-transform: uppercase; + font-weight: var(--font-weight-medium); + letter-spacing: 0.05px; + text-decoration: underline; + + body.theme-dark & { + color: var(--gray-50); + } } &__device-list { @@ -63,15 +61,6 @@ } } - &__show-self-fingerprint { - margin-bottom: 56px; - } - - &__reset-session { - font-size: @font-size-xsmall; - text-transform: uppercase; - } - &__single-client { margin-top: 40px; } @@ -79,11 +68,21 @@ &__verify { display: flex; justify-content: space-between; + + margin-bottom: 16px; } - &__actions { - .flex-center; + .device-details__reset-fingerprint { + margin-bottom: 8px; + } + + .device-proteus-details { + padding-top: 24px; + } - width: 50%; + .device-details-title, + .panel__info-text, + .participant-devices__fingerprint { + margin-bottom: 16px; } } diff --git a/yarn.lock b/yarn.lock index 1db4dc73af6..76dc0a91f9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4682,130 +4682,130 @@ __metadata: languageName: node linkType: hard -"@peculiar/asn1-cms@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-cms@npm:2.3.6" +"@peculiar/asn1-cms@npm:^2.3.6, @peculiar/asn1-cms@npm:^2.3.8": + version: 2.3.8 + resolution: "@peculiar/asn1-cms@npm:2.3.8" dependencies: - "@peculiar/asn1-schema": ^2.3.6 - "@peculiar/asn1-x509": ^2.3.6 - "@peculiar/asn1-x509-attr": ^2.3.6 + "@peculiar/asn1-schema": ^2.3.8 + "@peculiar/asn1-x509": ^2.3.8 + "@peculiar/asn1-x509-attr": ^2.3.8 asn1js: ^3.0.5 - tslib: ^2.4.0 - checksum: caeb7fcb594b7158ecb31f2e0e3a85a79fb9a51b9204b3fbad398706f56f20dfb6478f37fc163b544ebe3fd6ade7606a71826684df040ae6870fe7a4d8286d8c + tslib: ^2.6.2 + checksum: 5c5f833ad62bd5ba9391dc2f1fe80e49e5067688ee54c1a3ee37b659507049b652888c6e78f6e38ee8c73a1093dbe531dd47a8e4e2b09842bda737e8952001c1 languageName: node linkType: hard "@peculiar/asn1-csr@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-csr@npm:2.3.6" + version: 2.3.8 + resolution: "@peculiar/asn1-csr@npm:2.3.8" dependencies: - "@peculiar/asn1-schema": ^2.3.6 - "@peculiar/asn1-x509": ^2.3.6 + "@peculiar/asn1-schema": ^2.3.8 + "@peculiar/asn1-x509": ^2.3.8 asn1js: ^3.0.5 - tslib: ^2.4.0 - checksum: 4989edc765476b02995fbee88ab3860cf657f0e62756fa0ec44cd95158e6276f58c2c8656e5f8bd2f6934650999b781f2ec7b65ec537fc41a1aeb387b2e32883 + tslib: ^2.6.2 + checksum: d2bc7641f5e71e55ebb0bcfbbdb63e5168ac676e5ee56ee549c61f63414d9699600d1267fca223d1d6dad4fdb0a50be2a125bee37828a91b9b4c138a2b8295b7 languageName: node linkType: hard "@peculiar/asn1-ecc@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-ecc@npm:2.3.6" + version: 2.3.8 + resolution: "@peculiar/asn1-ecc@npm:2.3.8" dependencies: - "@peculiar/asn1-schema": ^2.3.6 - "@peculiar/asn1-x509": ^2.3.6 + "@peculiar/asn1-schema": ^2.3.8 + "@peculiar/asn1-x509": ^2.3.8 asn1js: ^3.0.5 - tslib: ^2.4.0 - checksum: 4b9a383dd443fbb9699d79550e03d1185781885768d8c7b780e26a959344286a53539824fa4a3103e9e8393a7d062fe6820bf79abafb340dc18ee5ce81b1d470 + tslib: ^2.6.2 + checksum: c7db2004a03f88c35fcd21957c8c15bfbf29c137a636bbc35d51dcebbc287ec253762e127d50197f0b5910600e3641a738c8ca756a15741fc09272272b0303f4 languageName: node linkType: hard -"@peculiar/asn1-pfx@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-pfx@npm:2.3.6" +"@peculiar/asn1-pfx@npm:^2.3.8": + version: 2.3.8 + resolution: "@peculiar/asn1-pfx@npm:2.3.8" dependencies: - "@peculiar/asn1-cms": ^2.3.6 - "@peculiar/asn1-pkcs8": ^2.3.6 - "@peculiar/asn1-rsa": ^2.3.6 - "@peculiar/asn1-schema": ^2.3.6 + "@peculiar/asn1-cms": ^2.3.8 + "@peculiar/asn1-pkcs8": ^2.3.8 + "@peculiar/asn1-rsa": ^2.3.8 + "@peculiar/asn1-schema": ^2.3.8 asn1js: ^3.0.5 - tslib: ^2.4.0 - checksum: faa3abc62e4ba4e67f6377178df62c24146920475e4ddfee024b1e14b208947d9b136c1bb1b82d7a3a28b76a544687529338f6143f92bdf324db23bd6aa0c11a + tslib: ^2.6.2 + checksum: a01dda82d077d52b343faafd14d58892813c4c43e37fb13e7531ab761d18fd453c50b5f0dbdffeb9310f0f502f97ab668d966bd899bae9604bd0aa43cd3874ea languageName: node linkType: hard -"@peculiar/asn1-pkcs8@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-pkcs8@npm:2.3.6" +"@peculiar/asn1-pkcs8@npm:^2.3.8": + version: 2.3.8 + resolution: "@peculiar/asn1-pkcs8@npm:2.3.8" dependencies: - "@peculiar/asn1-schema": ^2.3.6 - "@peculiar/asn1-x509": ^2.3.6 + "@peculiar/asn1-schema": ^2.3.8 + "@peculiar/asn1-x509": ^2.3.8 asn1js: ^3.0.5 - tslib: ^2.4.0 - checksum: 17b2ea5f2350ba74d58084f95f4d739d30413112e85268fd55b308307bd6a6f006e06accaf3e181103a61bfb7efd2c21be7f914b48954845bef109b1b0a4bc46 + tslib: ^2.6.2 + checksum: c1337df0097104b500f9428f839a699a9222125c9cdd34c288c95e52f5d0eec533d75ac45e41200ca8e328d135d3eda7638d2e7e91d1791419a04cd14b2d36a1 languageName: node linkType: hard "@peculiar/asn1-pkcs9@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-pkcs9@npm:2.3.6" - dependencies: - "@peculiar/asn1-cms": ^2.3.6 - "@peculiar/asn1-pfx": ^2.3.6 - "@peculiar/asn1-pkcs8": ^2.3.6 - "@peculiar/asn1-schema": ^2.3.6 - "@peculiar/asn1-x509": ^2.3.6 - "@peculiar/asn1-x509-attr": ^2.3.6 + version: 2.3.8 + resolution: "@peculiar/asn1-pkcs9@npm:2.3.8" + dependencies: + "@peculiar/asn1-cms": ^2.3.8 + "@peculiar/asn1-pfx": ^2.3.8 + "@peculiar/asn1-pkcs8": ^2.3.8 + "@peculiar/asn1-schema": ^2.3.8 + "@peculiar/asn1-x509": ^2.3.8 + "@peculiar/asn1-x509-attr": ^2.3.8 asn1js: ^3.0.5 - tslib: ^2.4.0 - checksum: 3b8ca25b46ce9afc51b8cc72afab6fd32bb1dbf501eef68f997b35591c16c321384224fe478fd03fa644f3d540d719568767c7bd8bd1f617a5d9f8d318be0d2c + tslib: ^2.6.2 + checksum: 22ee32733a5abb14039c858093f4e79c989ed32c5ece746dd638afa23428f34a0a4c207126b75c2ae18198a20083fbc08dee9309771569eb8ef3734a37ff950f languageName: node linkType: hard -"@peculiar/asn1-rsa@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-rsa@npm:2.3.6" +"@peculiar/asn1-rsa@npm:^2.3.6, @peculiar/asn1-rsa@npm:^2.3.8": + version: 2.3.8 + resolution: "@peculiar/asn1-rsa@npm:2.3.8" dependencies: - "@peculiar/asn1-schema": ^2.3.6 - "@peculiar/asn1-x509": ^2.3.6 + "@peculiar/asn1-schema": ^2.3.8 + "@peculiar/asn1-x509": ^2.3.8 asn1js: ^3.0.5 - tslib: ^2.4.0 - checksum: 120dda00af6e1b1e5568826ac8211d60d36b3cbe91b086cae6b5ba132f1670ba129284068110305b237550e402c0beeda45fd713d640f97ad11d8cf6c925b31a + tslib: ^2.6.2 + checksum: d9bf0f143686b475d3cc9f9b7d948826dc8c8764bc865697705351278541f0bf31a8f788ec8ff8bf6e4150b04aa65b20853bda45d77e4abbac717d7019e6fd56 languageName: node linkType: hard -"@peculiar/asn1-schema@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-schema@npm:2.3.6" +"@peculiar/asn1-schema@npm:^2.3.6, @peculiar/asn1-schema@npm:^2.3.8": + version: 2.3.8 + resolution: "@peculiar/asn1-schema@npm:2.3.8" dependencies: asn1js: ^3.0.5 - pvtsutils: ^1.3.2 - tslib: ^2.4.0 - checksum: fc09387c6e3dea07fca21b54ea8c71ce3ec0f8c92377237e51aef729f0c2df92781aa7a18a546a6fe809519faeaa222df576ec21a35c6095037a78677204a55b + pvtsutils: ^1.3.5 + tslib: ^2.6.2 + checksum: 1f4dd421f1411df8bc52bca12b1cef710434c13ff0a8b5746ede42b10d62b5ad06a3925c4a6db53102aaf1e589947539a6955fa8554a9b8ebb1ffa38b0155a24 languageName: node linkType: hard -"@peculiar/asn1-x509-attr@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-x509-attr@npm:2.3.6" +"@peculiar/asn1-x509-attr@npm:^2.3.8": + version: 2.3.8 + resolution: "@peculiar/asn1-x509-attr@npm:2.3.8" dependencies: - "@peculiar/asn1-schema": ^2.3.6 - "@peculiar/asn1-x509": ^2.3.6 + "@peculiar/asn1-schema": ^2.3.8 + "@peculiar/asn1-x509": ^2.3.8 asn1js: ^3.0.5 - tslib: ^2.4.0 - checksum: 100a11aad2168a99b23d576869d27d569c34191d14311cf6fcbea126b737bcb42f23401ead45c2bc55074d164712c65e5541be23c0e5f92bf19005957a16a872 + tslib: ^2.6.2 + checksum: 07a64f1cf50af87f510aa857794e9f9334f36bd1682df3ece7bb4935575bca36111e0ba263e8559a738ae77116bc3ee576c2d4d713b2c720fa31a809452b2f4e languageName: node linkType: hard -"@peculiar/asn1-x509@npm:^2.3.6": - version: 2.3.6 - resolution: "@peculiar/asn1-x509@npm:2.3.6" +"@peculiar/asn1-x509@npm:^2.3.6, @peculiar/asn1-x509@npm:^2.3.8": + version: 2.3.8 + resolution: "@peculiar/asn1-x509@npm:2.3.8" dependencies: - "@peculiar/asn1-schema": ^2.3.6 + "@peculiar/asn1-schema": ^2.3.8 asn1js: ^3.0.5 - ipaddr.js: ^2.0.1 - pvtsutils: ^1.3.2 - tslib: ^2.4.0 - checksum: 6e946bd44091fb88f617c3bbf54ed1113ed2b249675dd36004513444f409160f6d446bdb82d3cb6041b4d15c68fa4cf40ad452891a5f85dda2af89ee5b0590d2 + ipaddr.js: ^2.1.0 + pvtsutils: ^1.3.5 + tslib: ^2.6.2 + checksum: 23856e5d024298447afca55bd68d19a7440c0ae076437aee5ced26a0fa2e4efa3e0e4a354fa6ee9968d62ac21ee1c2186fc427942bacfc824d3a3a4d2e80d14b languageName: node linkType: hard @@ -6132,9 +6132,9 @@ __metadata: languageName: node linkType: hard -"@wireapp/api-client@npm:^26.5.3": - version: 26.5.3 - resolution: "@wireapp/api-client@npm:26.5.3" +"@wireapp/api-client@npm:^26.6.0": + version: 26.6.0 + resolution: "@wireapp/api-client@npm:26.6.0" dependencies: "@wireapp/commons": ^5.2.3 "@wireapp/priority-queue": ^2.1.4 @@ -6149,7 +6149,7 @@ __metadata: tough-cookie: 4.1.3 ws: 8.14.2 zod: 3.22.4 - checksum: 6520b95ebd8ccc59b576c890651bd20c782fa3054b303e857a428f09dbcc37ce6a661e3ee27cdc182b896481e1e7a610c796430fb9c40cffb3fb8fe4b74afe5d + checksum: 3bfe5c48c4f08b40fa254272be8be778b5a2909348876ba2690fb1dcda93ba89852d247a5895fcb534941960d0207c9eb7b0fc917f6f52fc3e1aaef65ab76fd3 languageName: node linkType: hard @@ -6202,11 +6202,11 @@ __metadata: languageName: node linkType: hard -"@wireapp/core@npm:42.21.0": - version: 42.21.0 - resolution: "@wireapp/core@npm:42.21.0" +"@wireapp/core@npm:42.25.2": + version: 42.25.2 + resolution: "@wireapp/core@npm:42.25.2" dependencies: - "@wireapp/api-client": ^26.5.3 + "@wireapp/api-client": ^26.6.0 "@wireapp/commons": ^5.2.3 "@wireapp/core-crypto": 1.0.0-rc.19 "@wireapp/cryptobox": 12.8.0 @@ -6224,7 +6224,7 @@ __metadata: long: ^5.2.0 uuidjs: 4.2.13 zod: 3.22.4 - checksum: 0513c3e4387071cc4b432ce600572dbed139fe5ce96209c01b2b2e2ecc9d5670c8a81f0b2c23c9576cb537095ce2781cd0bb2856d41e8b9bcd4beb29c99ae708 + checksum: f44a5c1eeea7e9c13c1a5bc01e19411229f8a1e067243927d604982eb910e0f6cd0489d38568301620e7b9604e68e0cc72fea8d9d0a125376fb556a8c0de6c68 languageName: node linkType: hard @@ -11466,7 +11466,7 @@ __metadata: languageName: node linkType: hard -"ipaddr.js@npm:^2.0.1": +"ipaddr.js@npm:^2.1.0": version: 2.1.0 resolution: "ipaddr.js@npm:2.1.0" checksum: 807a054f2bd720c4d97ee479d6c9e865c233bea21f139fb8dabd5a35c4226d2621c42e07b4ad94ff3f82add926a607d8d9d37c625ad0319f0e08f9f2bd1968e2 @@ -19268,7 +19268,7 @@ __metadata: "@wireapp/avs": 9.5.2 "@wireapp/commons": 5.2.3 "@wireapp/copy-config": 2.1.12 - "@wireapp/core": 42.21.0 + "@wireapp/core": 42.25.2 "@wireapp/eslint-config": 3.0.4 "@wireapp/prettier-config": 0.6.3 "@wireapp/react-ui-kit": 9.12.0