From 3c3df11d32f439cd84d057f7ef54dbd3fd75fd53 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 19 Oct 2022 13:31:20 +0100 Subject: [PATCH] Support for login + E2EE set up with QR (#9403) * Support for login + E2EE set up with QR * Whitespace * Padding * Refactor of fetch * Whitespace * CSS whitespace * Add link to MSC3906 * Handle incorrect typing in MatrixClientPeg.get() * Use unstable class name * fix: use unstable class name * Use default fetch client instead * Update to revised function name * Refactor device manager panel and make it work with new sessions manager * Lint fix * Add missing interstitials and update wording * Linting * i18n * Lint * Use sensible sdk config name for fallback server * Improve error handling for QR code generation * Refactor feature availability logic * Hide device manager panel if no options available * Put sign in with QR behind lab setting * Reduce scope of PR to just showing code on existing device * i18n updates * Handle null features * Testing for LoginWithQRSection * Refactor to handle UIA * Imports * Reduce diff complexity * Remove unnecessary change * Remove unused styles * Support UIA * Tidy up * i18n * Remove additional unused parts of flow * Add extra instruction when showing QR code * Add getVersions to server mocks * Use proper colours for theme support * Test cases * Lint * Remove obsolete snapshot * Don't override error if already set * Remove unused var * Update src/components/views/settings/devices/LoginWithQRSection.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update res/css/views/auth/_LoginWithQR.pcss Co-authored-by: Kerry * Use spacing variables * Remove debug * Style + docs * preventDefault * Names of tests * Fixes for js-sdk refactor * Update snapshots to match test names * Refactor labs config to make deployment simpler * i18n * Unused imports * Typo * Stateless component * Whitespace * Use context not MatrixClientPeg * Add missing context * Type updates to match js-sdk * Wrap click handlers in useCallback * Update src/components/views/settings/DevicesPanel.tsx Co-authored-by: Travis Ralston * Wait for DOM update instead of timeout * Add missing snapshot update from last commit * Remove void keyword in favour of then() clauses * test main paths in LoginWithQR Co-authored-by: Travis Ralston Co-authored-by: Kerry --- res/css/_components.pcss | 1 + res/css/views/auth/_LoginWithQR.pcss | 171 ++++++++ res/img/element-icons/back.svg | 3 + res/img/element-icons/devices.svg | 11 + res/img/element-icons/qrcode.svg | 4 + src/components/views/auth/LoginWithQR.tsx | 396 ++++++++++++++++++ .../views/dialogs/InteractiveAuthDialog.tsx | 6 +- .../views/settings/DevicesPanel.tsx | 22 +- .../settings/devices/LoginWithQRSection.tsx | 63 +++ .../views/settings/devices/useOwnDevices.ts | 7 + .../tabs/user/SecurityUserSettingsTab.tsx | 28 ++ .../settings/tabs/user/SessionManagerTab.tsx | 29 ++ src/i18n/strings/en_EN.json | 24 ++ src/settings/Settings.tsx | 10 + src/utils/UserInteractiveAuth.ts | 55 +++ .../views/settings/DevicesPanel-test.tsx | 6 +- .../settings/devices/LoginWithQR-test.tsx | 297 +++++++++++++ .../devices/LoginWithQRSection-test.tsx | 94 +++++ .../__snapshots__/LoginWithQR-test.tsx.snap | 367 ++++++++++++++++ .../LoginWithQRSection-test.tsx.snap | 45 ++ .../user/SecurityUserSettingsTab-test.tsx | 9 +- .../tabs/user/SessionManagerTab-test.tsx | 1 + test/test-utils/client.ts | 1 + 23 files changed, 1638 insertions(+), 12 deletions(-) create mode 100644 res/css/views/auth/_LoginWithQR.pcss create mode 100644 res/img/element-icons/back.svg create mode 100644 res/img/element-icons/devices.svg create mode 100644 res/img/element-icons/qrcode.svg create mode 100644 src/components/views/auth/LoginWithQR.tsx create mode 100644 src/components/views/settings/devices/LoginWithQRSection.tsx create mode 100644 src/utils/UserInteractiveAuth.ts create mode 100644 test/components/views/settings/devices/LoginWithQR-test.tsx create mode 100644 test/components/views/settings/devices/LoginWithQRSection-test.tsx create mode 100644 test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap create mode 100644 test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 00661bd56b..b2fcb0dd4f 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -96,6 +96,7 @@ @import "./views/auth/_CountryDropdown.pcss"; @import "./views/auth/_InteractiveAuthEntryComponents.pcss"; @import "./views/auth/_LanguageSelector.pcss"; +@import "./views/auth/_LoginWithQR.pcss"; @import "./views/auth/_PassphraseField.pcss"; @import "./views/auth/_Welcome.pcss"; @import "./views/avatars/_BaseAvatar.pcss"; diff --git a/res/css/views/auth/_LoginWithQR.pcss b/res/css/views/auth/_LoginWithQR.pcss new file mode 100644 index 0000000000..390cf8311d --- /dev/null +++ b/res/css/views/auth/_LoginWithQR.pcss @@ -0,0 +1,171 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LoginWithQRSection .mx_AccessibleButton { + margin-right: $spacing-12; +} + +.mx_AuthPage .mx_LoginWithQR { + .mx_AccessibleButton { + display: block !important; + } + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-top: $spacing-8; + } + + .mx_LoginWithQR_separator { + display: flex; + align-items: center; + text-align: center; + + &::before, &::after { + content: ''; + flex: 1; + border-bottom: 1px solid $quinary-content; + } + + &:not(:empty) { + &::before { + margin-right: 1em; + } + &::after { + margin-left: 1em; + } + } + } + + font-size: $font-15px; +} + +.mx_UserSettingsDialog .mx_LoginWithQR { + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: $spacing-12; + } + + font-size: $font-14px; + + h1 { + font-size: $font-24px; + margin-bottom: 0; + } + + li { + line-height: 1.8; + } + + .mx_QRCode { + padding: $spacing-12 $spacing-40; + margin: $spacing-28 0; + } + + .mx_LoginWithQR_buttons { + text-align: center; + } + + .mx_LoginWithQR_qrWrapper { + display: flex; + } +} + +.mx_LoginWithQR { + min-height: 350px; + display: flex; + flex-direction: column; + + .mx_LoginWithQR_centreTitle { + h1 { + text-align: centre; + } + } + + h1 > svg { + &.normal { + color: $secondary-content; + } + &.error { + color: $alert; + } + &.success { + color: $accent; + } + height: 1.3em; + margin-right: $spacing-8; + vertical-align: middle; + } + + .mx_LoginWithQR_confirmationDigits { + text-align: center; + margin: $spacing-48 auto; + font-weight: 600; + font-size: $font-24px; + color: $primary-content; + } + + .mx_LoginWithQR_confirmationAlert { + border: 1px solid $quaternary-content; + border-radius: $spacing-8; + padding: $spacing-8; + line-height: 1.5em; + display: flex; + + svg { + height: 30px; + } + } + + .mx_LoginWithQR_separator { + margin: 1em 0; + } + + ol { + list-style-position: inside; + padding-inline-start: 0; + + li::marker { + color: $accent; + } + } + + .mx_LoginWithQR_BackButton { + height: $spacing-12; + margin-bottom: $spacing-24; + svg { + height: 100%; + } + } + + .mx_LoginWithQR_main { + display: flex; + flex-direction: column; + flex-grow: 1; + } + + .mx_QRCode { + border: 1px solid $quinary-content; + border-radius: $spacing-8; + display: flex; + justify-content: center; + } + + .mx_LoginWithQR_spinner { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + } +} diff --git a/res/img/element-icons/back.svg b/res/img/element-icons/back.svg new file mode 100644 index 0000000000..62aef5df27 --- /dev/null +++ b/res/img/element-icons/back.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/res/img/element-icons/devices.svg b/res/img/element-icons/devices.svg new file mode 100644 index 0000000000..6c26cfe97e --- /dev/null +++ b/res/img/element-icons/devices.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/res/img/element-icons/qrcode.svg b/res/img/element-icons/qrcode.svg new file mode 100644 index 0000000000..7787141ad5 --- /dev/null +++ b/res/img/element-icons/qrcode.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx new file mode 100644 index 0000000000..f95e618cc5 --- /dev/null +++ b/src/components/views/auth/LoginWithQR.tsx @@ -0,0 +1,396 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { MSC3906Rendezvous, MSC3906RendezvousPayload, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; +import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports'; +import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from 'matrix-js-sdk/src/rendezvous/channels'; +import { logger } from 'matrix-js-sdk/src/logger'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; + +import { _t } from "../../../languageHandler"; +import AccessibleButton from '../elements/AccessibleButton'; +import QRCode from '../elements/QRCode'; +import Spinner from '../elements/Spinner'; +import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg"; +import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg"; +import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg"; +import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; +import { wrapRequestWithDialog } from '../../../utils/UserInteractiveAuth'; + +/** + * The intention of this enum is to have a mode that scans a QR code instead of generating one. + */ +export enum Mode { + /** + * A QR code with be generated and shown + */ + Show = "show", +} + +enum Phase { + Loading, + ShowingQR, + Connecting, + Connected, + WaitingForDevice, + Verifying, + Error, +} + +interface IProps { + client: MatrixClient; + mode: Mode; + onFinished(...args: any): void; +} + +interface IState { + phase: Phase; + rendezvous?: MSC3906Rendezvous; + confirmationDigits?: string; + failureReason?: RendezvousFailureReason; + mediaPermissionError?: boolean; +} + +/** + * A component that allows sign in and E2EE set up with a QR code. + * + * It implements both `login.start` and `login-reciprocate` capabilities as well as both scanning and showing QR codes. + * + * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + */ +export default class LoginWithQR extends React.Component { + public constructor(props) { + super(props); + + this.state = { + phase: Phase.Loading, + }; + } + + public componentDidMount(): void { + this.updateMode(this.props.mode).then(() => {}); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.mode !== this.props.mode) { + this.updateMode(this.props.mode).then(() => {}); + } + } + + private async updateMode(mode: Mode) { + this.setState({ phase: Phase.Loading }); + if (this.state.rendezvous) { + this.state.rendezvous.onFailure = undefined; + await this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled); + this.setState({ rendezvous: undefined }); + } + if (mode === Mode.Show) { + await this.generateCode(); + } + } + + public componentWillUnmount(): void { + if (this.state.rendezvous) { + // eslint-disable-next-line react/no-direct-mutation-state + this.state.rendezvous.onFailure = undefined; + // calling cancel will call close() as well to clean up the resources + this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled).then(() => {}); + } + } + + private approveLogin = async (): Promise => { + if (!this.state.rendezvous) { + throw new Error('Rendezvous not found'); + } + this.setState({ phase: Phase.Loading }); + + try { + logger.info("Requesting login token"); + + const { login_token: loginToken } = await wrapRequestWithDialog(this.props.client.requestLoginToken, { + matrixClient: this.props.client, + title: _t("Sign in new device"), + })(); + + this.setState({ phase: Phase.WaitingForDevice }); + + const newDeviceId = await this.state.rendezvous.approveLoginOnExistingDevice(loginToken); + if (!newDeviceId) { + // user denied + return; + } + if (!this.props.client.crypto) { + // no E2EE to set up + this.props.onFinished(true); + return; + } + await this.state.rendezvous.verifyNewDeviceOnExistingDevice(); + this.props.onFinished(true); + } catch (e) { + logger.error('Error whilst approving sign in', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + }; + + private generateCode = async () => { + let rendezvous: MSC3906Rendezvous; + try { + const transport = new MSC3886SimpleHttpRendezvousTransport({ + onFailure: this.onFailure, + client: this.props.client, + }); + + const channel = new MSC3903ECDHv1RendezvousChannel( + transport, undefined, this.onFailure, + ); + + rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + + await rendezvous.generateCode(); + this.setState({ + phase: Phase.ShowingQR, + rendezvous, + failureReason: undefined, + }); + } catch (e) { + logger.error('Error whilst generating QR code', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.HomeserverLacksSupport }); + return; + } + + try { + const confirmationDigits = await rendezvous.startAfterShowingCode(); + this.setState({ phase: Phase.Connected, confirmationDigits }); + } catch (e) { + logger.error('Error whilst doing QR login', e); + // only set to error phase if it hasn't already been set by onFailure or similar + if (this.state.phase !== Phase.Error) { + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + } + }; + + private onFailure = (reason: RendezvousFailureReason) => { + logger.info(`Rendezvous failed: ${reason}`); + this.setState({ phase: Phase.Error, failureReason: reason }); + }; + + public reset() { + this.setState({ + rendezvous: undefined, + confirmationDigits: undefined, + failureReason: undefined, + }); + } + + private cancelClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + this.reset(); + this.props.onFinished(false); + }; + + private declineClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.declineLoginOnExistingDevice(); + this.reset(); + this.props.onFinished(false); + }; + + private tryAgainClicked = async (e: React.FormEvent) => { + e.preventDefault(); + this.reset(); + await this.updateMode(this.props.mode); + }; + + private onBackClick = async () => { + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + + this.props.onFinished(false); + }; + + private cancelButton = () => + { _t("Cancel") } + ; + + private simpleSpinner = (description?: string): JSX.Element => { + return
+
+ + { description &&

{ description }

} +
+
; + }; + + public render() { + let title: string; + let titleIcon: JSX.Element | undefined; + let main: JSX.Element | undefined; + let buttons: JSX.Element | undefined; + let backButton = true; + let cancellationMessage: string | undefined; + let centreTitle = false; + + switch (this.state.phase) { + case Phase.Error: + switch (this.state.failureReason) { + case RendezvousFailureReason.Expired: + cancellationMessage = _t("The linking wasn't completed in the required time."); + break; + case RendezvousFailureReason.InvalidCode: + cancellationMessage = _t("The scanned code is invalid."); + break; + case RendezvousFailureReason.UnsupportedAlgorithm: + cancellationMessage = _t("Linking with this device is not supported."); + break; + case RendezvousFailureReason.UserDeclined: + cancellationMessage = _t("The request was declined on the other device."); + break; + case RendezvousFailureReason.OtherDeviceAlreadySignedIn: + cancellationMessage = _t("The other device is already signed in."); + break; + case RendezvousFailureReason.OtherDeviceNotSignedIn: + cancellationMessage = _t("The other device isn't signed in."); + break; + case RendezvousFailureReason.UserCancelled: + cancellationMessage = _t("The request was cancelled."); + break; + case RendezvousFailureReason.Unknown: + cancellationMessage = _t("An unexpected error occurred."); + break; + case RendezvousFailureReason.HomeserverLacksSupport: + cancellationMessage = _t("The homeserver doesn't support signing in another device."); + break; + default: + cancellationMessage = _t("The request was cancelled."); + break; + } + title = _t("Connection failed"); + centreTitle = true; + titleIcon = ; + backButton = false; + main =

{ cancellationMessage }

; + buttons = <> + + { _t("Try again") } + + { this.cancelButton() } + ; + break; + case Phase.Connected: + title = _t("Devices connected"); + titleIcon = ; + backButton = false; + main = <> +

{ _t("Check that the code below matches with your other device:") }

+
+ { this.state.confirmationDigits } +
+
+
+ +
+
{ _t("By approving access for this device, it will have full access to your account.") }
+
+ ; + + buttons = <> + + { _t("Cancel") } + + + { _t("Approve") } + + ; + break; + case Phase.ShowingQR: + title =_t("Sign in with QR code"); + if (this.state.rendezvous) { + const code =
+ +
; + main = <> +

{ _t("Scan the QR code below with your device that's signed out.") }

+
    +
  1. { _t("Start at the sign in screen") }
  2. +
  3. { _t("Select 'Scan QR code'") }
  4. +
  5. { _t("Review and approve the sign in") }
  6. +
+ { code } + ; + } else { + main = this.simpleSpinner(); + buttons = this.cancelButton(); + } + break; + case Phase.Loading: + main = this.simpleSpinner(); + break; + case Phase.Connecting: + main = this.simpleSpinner(_t("Connecting...")); + buttons = this.cancelButton(); + break; + case Phase.WaitingForDevice: + main = this.simpleSpinner(_t("Waiting for device to sign in")); + buttons = this.cancelButton(); + break; + case Phase.Verifying: + title = _t("Success"); + centreTitle = true; + main = this.simpleSpinner(_t("Completing set up of your new device")); + break; + } + + return ( +
+
+ { backButton ? + + + + : null } +

{ titleIcon }{ title }

+
+
+ { main } +
+
+ { buttons } +
+
+ ); + } +} diff --git a/src/components/views/dialogs/InteractiveAuthDialog.tsx b/src/components/views/dialogs/InteractiveAuthDialog.tsx index 6f10790811..5d8fc2f952 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.tsx +++ b/src/components/views/dialogs/InteractiveAuthDialog.tsx @@ -38,7 +38,7 @@ interface IDialogAesthetics { }; } -interface IProps extends IDialogProps { +export interface InteractiveAuthDialogProps extends IDialogProps { // matrix client to use for UI auth requests matrixClient: MatrixClient; @@ -82,8 +82,8 @@ interface IState { uiaStagePhase: number | string; } -export default class InteractiveAuthDialog extends React.Component { - constructor(props: IProps) { +export default class InteractiveAuthDialog extends React.Component { + constructor(props: InteractiveAuthDialogProps) { super(props); this.state = { diff --git a/src/components/views/settings/DevicesPanel.tsx b/src/components/views/settings/DevicesPanel.tsx index f32f7997fe..1b06fa06fe 100644 --- a/src/components/views/settings/DevicesPanel.tsx +++ b/src/components/views/settings/DevicesPanel.tsx @@ -19,13 +19,14 @@ import classNames from 'classnames'; import { IMyDevice } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { CryptoEvent } from 'matrix-js-sdk/src/crypto'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import DevicesPanelEntry from "./DevicesPanelEntry"; import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; import { deleteDevicesWithInteractiveAuth } from './devices/deleteDevices'; +import MatrixClientContext from '../../../contexts/MatrixClientContext'; interface IProps { className?: string; @@ -40,6 +41,8 @@ interface IState { } export default class DevicesPanel extends React.Component { + public static contextType = MatrixClientContext; + public context!: React.ContextType; private unmounted = false; constructor(props: IProps) { @@ -52,15 +55,22 @@ export default class DevicesPanel extends React.Component { } public componentDidMount(): void { + this.context.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); this.loadDevices(); } public componentWillUnmount(): void { + this.context.off(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); this.unmounted = true; } + private onDevicesUpdated = (users: string[]) => { + if (!users.includes(this.context.getUserId())) return; + this.loadDevices(); + }; + private loadDevices(): void { - const cli = MatrixClientPeg.get(); + const cli = this.context; cli.getDevices().then( (resp) => { if (this.unmounted) { return; } @@ -111,7 +121,7 @@ export default class DevicesPanel extends React.Component { private isDeviceVerified(device: IMyDevice): boolean | null { try { - const cli = MatrixClientPeg.get(); + const cli = this.context; const deviceInfo = cli.getStoredDevice(cli.getUserId(), device.device_id); return this.state.crossSigningInfo.checkDeviceTrust( this.state.crossSigningInfo, @@ -184,7 +194,7 @@ export default class DevicesPanel extends React.Component { try { await deleteDevicesWithInteractiveAuth( - MatrixClientPeg.get(), + this.context, this.state.selectedDevices, (success) => { if (success) { @@ -208,7 +218,7 @@ export default class DevicesPanel extends React.Component { }; private renderDevice = (device: IMyDevice): JSX.Element => { - const myDeviceId = MatrixClientPeg.get().getDeviceId(); + const myDeviceId = this.context.getDeviceId(); const myDevice = this.state.devices.find((device) => (device.device_id === myDeviceId)); const isOwnDevice = device.device_id === myDeviceId; @@ -246,7 +256,7 @@ export default class DevicesPanel extends React.Component { return ; } - const myDeviceId = MatrixClientPeg.get().getDeviceId(); + const myDeviceId = this.context.getDeviceId(); const myDevice = devices.find((device) => (device.device_id === myDeviceId)); if (!myDevice) { diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx new file mode 100644 index 0000000000..20cdb37902 --- /dev/null +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -0,0 +1,63 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import type { IServerVersions } from 'matrix-js-sdk/src/matrix'; +import { _t } from '../../../../languageHandler'; +import AccessibleButton from '../../elements/AccessibleButton'; +import SettingsSubsection from '../shared/SettingsSubsection'; +import SettingsStore from '../../../../settings/SettingsStore'; + +interface IProps { + onShowQr: () => void; + versions: IServerVersions; +} + +export default class LoginWithQRSection extends React.Component { + public constructor(props: IProps) { + super(props); + } + + public render(): JSX.Element { + const msc3882Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3882']; + const msc3886Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3886']; + + // Needs to be enabled as a feature + server support MSC3886 or have a default rendezvous server configured: + const offerShowQr = SettingsStore.getValue("feature_qr_signin_reciprocate_show") && + msc3882Supported && msc3886Supported; // We don't support configuration of a fallback at the moment so we just check the MSCs + + // don't show anything if no method is available + if (!offerShowQr) { + return null; + } + + return +
+

{ + _t("You can use this device to sign in a new device with a QR code. You will need to " + + "scan the QR code shown on this device with your device that's signed out.") + }

+ { _t("Show QR code") } +
+
; + } +} diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index c3b8cb0212..f56ed85c87 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -31,6 +31,7 @@ import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/reque import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import { _t } from "../../../../languageHandler"; @@ -179,6 +180,12 @@ export const useOwnDevices = (): DevicesState => { refreshDevices(); }, [refreshDevices]); + useEventEmitter(matrixClient, CryptoEvent.DevicesUpdated, (users: string[]): void => { + if (users.includes(userId)) { + refreshDevices(); + } + }); + useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => { const type = event.getType(); if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index f4e4e55513..b960e65a61 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -38,6 +38,9 @@ import InlineSpinner from "../../../elements/InlineSpinner"; import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog"; import { privateShouldBeEncrypted } from "../../../../../utils/rooms"; +import LoginWithQR, { Mode } from '../../../auth/LoginWithQR'; +import LoginWithQRSection from '../../devices/LoginWithQRSection'; +import type { IServerVersions } from 'matrix-js-sdk/src/matrix'; interface IIgnoredUserProps { userId: string; @@ -72,6 +75,8 @@ interface IState { waitingUnignored: string[]; managingInvites: boolean; invitedRoomIds: Set; + showLoginWithQR: Mode | null; + versions?: IServerVersions; } export default class SecurityUserSettingsTab extends React.Component { @@ -88,6 +93,7 @@ export default class SecurityUserSettingsTab extends React.Component this.setState({ versions })); } public componentWillUnmount(): void { @@ -251,6 +258,14 @@ export default class SecurityUserSettingsTab extends React.Component { + this.setState({ showLoginWithQR: Mode.Show }); + }; + + private onLoginWithQRFinished = (): void => { + this.setState({ showLoginWithQR: null }); + }; + public render(): JSX.Element { const secureBackup = (
@@ -347,6 +362,7 @@ export default class SecurityUserSettingsTab extends React.Component @@ -363,8 +379,20 @@ export default class SecurityUserSettingsTab extends React.Component
+ { showQrCodeEnabled ? + + : null + } ; + const client = MatrixClientPeg.get(); + + if (showQrCodeEnabled && this.state.showLoginWithQR) { + return
+ +
; + } + return (
{ warning } diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index d1fbb6ce5c..49ca1bdbf2 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -32,6 +32,10 @@ import SecurityRecommendations from '../../devices/SecurityRecommendations'; import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types'; import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices'; import SettingsTab from '../SettingsTab'; +import LoginWithQRSection from '../../devices/LoginWithQRSection'; +import LoginWithQR, { Mode } from '../../../auth/LoginWithQR'; +import SettingsStore from '../../../../../settings/SettingsStore'; +import { useAsyncMemo } from '../../../../../hooks/useAsyncMemo'; const useSignOut = ( matrixClient: MatrixClient, @@ -104,6 +108,7 @@ const SessionManagerTab: React.FC = () => { const matrixClient = useContext(MatrixClientContext); const userId = matrixClient.getUserId(); const currentUserMember = userId && matrixClient.getUser(userId) || undefined; + const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]); const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => { if (expandedDeviceIds.includes(deviceId)) { @@ -175,6 +180,26 @@ const SessionManagerTab: React.FC = () => { onSignOutOtherDevices(Object.keys(otherDevices)); }: undefined; + const [signInWithQrMode, setSignInWithQrMode] = useState(); + + const showQrCodeEnabled = SettingsStore.getValue("feature_qr_signin_reciprocate_show"); + + const onQrFinish = useCallback(() => { + setSignInWithQrMode(null); + }, [setSignInWithQrMode]); + + const onShowQrClicked = useCallback(() => { + setSignInWithQrMode(Mode.Show); + }, [setSignInWithQrMode]); + + if (showQrCodeEnabled && signInWithQrMode) { + return ; + } + return { /> } + { showQrCodeEnabled ? + + : null + } ; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3f078172b3..3f69088f78 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -935,6 +935,7 @@ "New session manager": "New session manager", "Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.", "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -1788,6 +1789,9 @@ "Filter devices": "Filter devices", "Show": "Show", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected", + "Sign in with QR code": "Sign in with QR code", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.", + "Show QR code": "Show QR code", "Security recommendations": "Security recommendations", "Improve your account security by following these recommendations": "Improve your account security by following these recommendations", "View all": "View all", @@ -3181,6 +3185,26 @@ "Submit": "Submit", "Something went wrong in confirming your identity. Cancel and try again.": "Something went wrong in confirming your identity. Cancel and try again.", "Start authentication": "Start authentication", + "Sign in new device": "Sign in new device", + "The linking wasn't completed in the required time.": "The linking wasn't completed in the required time.", + "The scanned code is invalid.": "The scanned code is invalid.", + "Linking with this device is not supported.": "Linking with this device is not supported.", + "The request was declined on the other device.": "The request was declined on the other device.", + "The other device is already signed in.": "The other device is already signed in.", + "The other device isn't signed in.": "The other device isn't signed in.", + "The request was cancelled.": "The request was cancelled.", + "An unexpected error occurred.": "An unexpected error occurred.", + "The homeserver doesn't support signing in another device.": "The homeserver doesn't support signing in another device.", + "Devices connected": "Devices connected", + "Check that the code below matches with your other device:": "Check that the code below matches with your other device:", + "By approving access for this device, it will have full access to your account.": "By approving access for this device, it will have full access to your account.", + "Scan the QR code below with your device that's signed out.": "Scan the QR code below with your device that's signed out.", + "Start at the sign in screen": "Start at the sign in screen", + "Select 'Scan QR code'": "Select 'Scan QR code'", + "Review and approve the sign in": "Review and approve the sign in", + "Connecting...": "Connecting...", + "Waiting for device to sign in": "Waiting for device to sign in", + "Completing set up of your new device": "Completing set up of your new device", "Enter password": "Enter password", "Nice, strong password!": "Nice, strong password!", "Password is allowed, but unsafe": "Password is allowed, but unsafe", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 9b6e09c772..723b789ab0 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -494,6 +494,16 @@ export const SETTINGS: {[setting: string]: ISetting} = { , }, }, + "feature_qr_signin_reciprocate_show": { + isFeature: true, + labsGroup: LabGroup.Experimental, + supportedLevels: LEVELS_FEATURE, + displayName: _td( + "Allow a QR code to be shown in session manager to sign in another device " + + "(requires compatible homeserver)", + ), + default: false, + }, "baseFontSize": { displayName: _td("Font size"), supportedLevels: LEVELS_ACCOUNT_SETTINGS, diff --git a/src/utils/UserInteractiveAuth.ts b/src/utils/UserInteractiveAuth.ts new file mode 100644 index 0000000000..e3088fb3cb --- /dev/null +++ b/src/utils/UserInteractiveAuth.ts @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IAuthData } from "matrix-js-sdk/src/interactive-auth"; +import { UIAResponse } from "matrix-js-sdk/src/@types/uia"; + +import Modal from "../Modal"; +import InteractiveAuthDialog, { InteractiveAuthDialogProps } from "../components/views/dialogs/InteractiveAuthDialog"; + +type FunctionWithUIA = (auth?: IAuthData, ...args: A[]) => Promise>; + +export function wrapRequestWithDialog( + requestFunction: FunctionWithUIA, + opts: Omit, +): ((...args: A[]) => Promise) { + return async function(...args): Promise { + return new Promise((resolve, reject) => { + const boundFunction = requestFunction.bind(opts.matrixClient) as FunctionWithUIA; + boundFunction(undefined, ...args) + .then((res) => resolve(res as R)) + .catch(error => { + if (error.httpStatus !== 401 || !error.data?.flows) { + // doesn't look like an interactive-auth failure + return reject(error); + } + + Modal.createDialog(InteractiveAuthDialog, { + ...opts, + authData: error.data, + makeRequest: (authData) => boundFunction(authData, ...args), + onFinished: (success, result) => { + if (success) { + resolve(result); + } else { + reject(result); + } + }, + }); + }); + }); + }; +} diff --git a/test/components/views/settings/DevicesPanel-test.tsx b/test/components/views/settings/DevicesPanel-test.tsx index a7baf139af..81f6fb328a 100644 --- a/test/components/views/settings/DevicesPanel-test.tsx +++ b/test/components/views/settings/DevicesPanel-test.tsx @@ -28,6 +28,7 @@ import { mkPusher, mockClientMethodsUser, } from "../../../test-utils"; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; describe('', () => { const userId = '@alice:server.org'; @@ -46,7 +47,10 @@ describe('', () => { setPusher: jest.fn(), }); - const getComponent = () => ; + const getComponent = () => + + + ; beforeEach(() => { jest.clearAllMocks(); diff --git a/test/components/views/settings/devices/LoginWithQR-test.tsx b/test/components/views/settings/devices/LoginWithQR-test.tsx new file mode 100644 index 0000000000..c106b2f9a8 --- /dev/null +++ b/test/components/views/settings/devices/LoginWithQR-test.tsx @@ -0,0 +1,297 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { mocked } from 'jest-mock'; +import React from 'react'; +import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports'; +import { MSC3906Rendezvous, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; + +import LoginWithQR, { Mode } from '../../../../../src/components/views/auth/LoginWithQR'; +import type { MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { flushPromisesWithFakeTimers } from '../../../../test-utils'; + +jest.useFakeTimers(); + +jest.mock('matrix-js-sdk/src/rendezvous'); +jest.mock('matrix-js-sdk/src/rendezvous/transports'); +jest.mock('matrix-js-sdk/src/rendezvous/channels'); + +function makeClient() { + return mocked({ + getUser: jest.fn(), + isGuest: jest.fn().mockReturnValue(false), + isUserIgnored: jest.fn(), + isCryptoEnabled: jest.fn(), + getUserId: jest.fn(), + on: jest.fn(), + isSynapseAdministrator: jest.fn().mockResolvedValue(false), + isRoomEncrypted: jest.fn().mockReturnValue(false), + mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), + doesServerSupportUnstableFeature: jest.fn().mockReturnValue(true), + removeListener: jest.fn(), + requestLoginToken: jest.fn(), + currentState: { + on: jest.fn(), + }, + } as unknown as MatrixClient); +} + +describe('', () => { + const client = makeClient(); + const defaultProps = { + mode: Mode.Show, + onFinished: jest.fn(), + }; + const mockConfirmationDigits = 'mock-confirmation-digits'; + const newDeviceId = 'new-device-id'; + + const getComponent = (props: { client: MatrixClient, onFinished?: () => void }) => + (); + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockRestore(); + jest.spyOn(MSC3906Rendezvous.prototype, 'cancel').mockResolvedValue(); + jest.spyOn(MSC3906Rendezvous.prototype, 'declineLoginOnExistingDevice').mockResolvedValue(); + jest.spyOn(MSC3906Rendezvous.prototype, 'startAfterShowingCode').mockResolvedValue(mockConfirmationDigits); + jest.spyOn(MSC3906Rendezvous.prototype, 'approveLoginOnExistingDevice').mockResolvedValue(newDeviceId); + client.requestLoginToken.mockResolvedValue({ + login_token: 'token', + expires_in: 1000, + }); + // @ts-ignore + client.crypto = undefined; + }); + + it('no content in case of no support', async () => { + // simulate no support + jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockRejectedValue(''); + const { container } = render(getComponent({ client })); + await waitFor(() => screen.getAllByTestId('cancellation-message').length === 1); + expect(container).toMatchSnapshot(); + }); + + it('renders spinner while generating code', async () => { + const { container } = render(getComponent({ client })); + expect(container).toMatchSnapshot(); + }); + + it('cancels rendezvous after user goes back', async () => { + const { getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('back-button')); + + // wait for cancel + await flushPromisesWithFakeTimers(); + + expect(rendezvous.cancel).toHaveBeenCalledWith(RendezvousFailureReason.UserCancelled); + }); + + it('displays qr code after it is created', async () => { + const { container, getByText } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + await flushPromisesWithFakeTimers(); + + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(getByText('Sign in with QR code')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it('displays confirmation digits after connected to rendezvous', async () => { + const { container, getByText } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + expect(container).toMatchSnapshot(); + expect(getByText(mockConfirmationDigits)).toBeTruthy(); + }); + + it('displays unknown error if connection to rendezvous fails', async () => { + const { container } = render(getComponent({ client })); + expect(MSC3886SimpleHttpRendezvousTransport).toHaveBeenCalledWith({ + onFailure: expect.any(Function), + client, + }); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + mocked(rendezvous).startAfterShowingCode.mockRejectedValue('oups'); + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + expect(container).toMatchSnapshot(); + }); + + it('declines login', async () => { + const { getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('decline-login-button')); + + expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled(); + }); + + it('displays error when approving login fails', async () => { + const { container, getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + client.requestLoginToken.mockRejectedValue('oups'); + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + expect(client.requestLoginToken).toHaveBeenCalled(); + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(container).toMatchSnapshot(); + }); + + it('approves login and waits for new device', async () => { + const { container, getByTestId, getByText } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + expect(client.requestLoginToken).toHaveBeenCalled(); + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(getByText('Waiting for device to sign in')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it('does not continue with verification when user denies login', async () => { + const onFinished = jest.fn(); + const { getByTestId } = render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + // no device id returned => user denied + mocked(rendezvous).approveLoginOnExistingDevice.mockReturnValue(undefined); + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); + + await flushPromisesWithFakeTimers(); + expect(onFinished).not.toHaveBeenCalled(); + expect(rendezvous.verifyNewDeviceOnExistingDevice).not.toHaveBeenCalled(); + }); + + it('waits for device approval on existing device and finishes when crypto is not setup', async () => { + const { getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); + await flushPromisesWithFakeTimers(); + expect(defaultProps.onFinished).toHaveBeenCalledWith(true); + // didnt attempt verification + expect(rendezvous.verifyNewDeviceOnExistingDevice).not.toHaveBeenCalled(); + }); + + it('waits for device approval on existing device and verifies device', async () => { + const { getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + // we just check for presence of crypto + // pretend it is set up + // @ts-ignore + client.crypto = {}; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); + // flush login approval + await flushPromisesWithFakeTimers(); + expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled(); + // flush verification + await flushPromisesWithFakeTimers(); + expect(defaultProps.onFinished).toHaveBeenCalledWith(true); + }); +}); diff --git a/test/components/views/settings/devices/LoginWithQRSection-test.tsx b/test/components/views/settings/devices/LoginWithQRSection-test.tsx new file mode 100644 index 0000000000..711f471035 --- /dev/null +++ b/test/components/views/settings/devices/LoginWithQRSection-test.tsx @@ -0,0 +1,94 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { render } from '@testing-library/react'; +import { mocked } from 'jest-mock'; +import { IServerVersions, MatrixClient } from 'matrix-js-sdk/src/matrix'; +import React from 'react'; + +import LoginWithQRSection from '../../../../../src/components/views/settings/devices/LoginWithQRSection'; +import { MatrixClientPeg } from '../../../../../src/MatrixClientPeg'; +import { SettingLevel } from '../../../../../src/settings/SettingLevel'; +import SettingsStore from '../../../../../src/settings/SettingsStore'; + +function makeClient() { + return mocked({ + getUser: jest.fn(), + isGuest: jest.fn().mockReturnValue(false), + isUserIgnored: jest.fn(), + isCryptoEnabled: jest.fn(), + getUserId: jest.fn(), + on: jest.fn(), + isSynapseAdministrator: jest.fn().mockResolvedValue(false), + isRoomEncrypted: jest.fn().mockReturnValue(false), + mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), + removeListener: jest.fn(), + currentState: { + on: jest.fn(), + }, + } as unknown as MatrixClient); +} + +function makeVersions(unstableFeatures: Record): IServerVersions { + return { + versions: [], + unstable_features: unstableFeatures, + }; +} + +describe('', () => { + beforeAll(() => { + jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(makeClient()); + }); + + const defaultProps = { + onShowQr: () => {}, + versions: undefined, + }; + + const getComponent = (props = {}) => + (); + + describe('should not render', () => { + it('no support at all', () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it('feature enabled', async () => { + await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true); + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it('only feature + MSC3882 enabled', async () => { + await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true); + const { container } = render(getComponent({ versions: makeVersions({ 'org.matrix.msc3882': true }) })); + expect(container).toMatchSnapshot(); + }); + }); + + describe('should render panel', () => { + it('enabled by feature + MSC3882 + MSC3886', async () => { + await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true); + const { container } = render(getComponent({ versions: makeVersions({ + 'org.matrix.msc3882': true, + 'org.matrix.msc3886': true, + }) })); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap new file mode 100644 index 0000000000..91fe73abf4 --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap @@ -0,0 +1,367 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` approves login and waits for new device 1`] = ` +
+
+
+
+
+
+

+

+
+
+
+
+
+
+

+ Waiting for device to sign in +

+
+
+
+
+
+ Cancel +
+
+
+
+`; + +exports[` displays confirmation digits after connected to rendezvous 1`] = ` +
+
+
+

+
+ Devices connected +

+
+
+

+ Check that the code below matches with your other device: +

+
+ mock-confirmation-digits +
+
+
+
+
+
+ By approving access for this device, it will have full access to your account. +
+
+
+
+
+ Cancel +
+
+ Approve +
+
+
+
+`; + +exports[` displays error when approving login fails 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ An unexpected error occurred. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` displays qr code after it is created 1`] = ` +
+
+
+
+
+
+

+ Sign in with QR code +

+
+
+

+ Scan the QR code below with your device that's signed out. +

+
    +
  1. + Start at the sign in screen +
  2. +
  3. + Select 'Scan QR code' +
  4. +
  5. + Review and approve the sign in +
  6. +
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` displays unknown error if connection to rendezvous fails 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ An unexpected error occurred. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` no content in case of no support 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The homeserver doesn't support signing in another device. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` renders spinner while generating code 1`] = ` +
+
+
+
+
+
+

+

+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap new file mode 100644 index 0000000000..2cf0d24cc6 --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should not render feature enabled 1`] = `
`; + +exports[` should not render no support at all 1`] = `
`; + +exports[` should not render only feature + MSC3882 enabled 1`] = `
`; + +exports[` should render panel enabled by feature + MSC3882 + MSC3886 1`] = ` +
+
+
+

+ Sign in with QR code +

+
+
+
+

+ You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out. +

+
+ Show QR code +
+
+
+
+
+`; diff --git a/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx index bddb493463..3497f2f161 100644 --- a/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx @@ -17,6 +17,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import SecurityUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/SecurityUserSettingsTab"; +import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext'; import SettingsStore from '../../../../../../src/settings/SettingsStore'; import { getMockClientWithEventEmitter, @@ -31,11 +32,10 @@ describe('', () => { const defaultProps = { closeSettingsFn: jest.fn(), }; - const getComponent = () => ; const userId = '@alice:server.org'; const deviceId = 'alices-device'; - getMockClientWithEventEmitter({ + const mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), ...mockClientMethodsServer(), ...mockClientMethodsDevice(deviceId), @@ -44,6 +44,11 @@ describe('', () => { getIgnoredUsers: jest.fn(), }); + const getComponent = () => + + + ; + const settingsValueSpy = jest.spyOn(SettingsStore, 'getValue'); beforeEach(() => { diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 5bcb6cc36c..7826b3cc80 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -92,6 +92,7 @@ describe('', () => { getPushers: jest.fn(), setPusher: jest.fn(), setLocalNotificationSettings: jest.fn(), + getVersions: jest.fn().mockResolvedValue({}), }); const defaultProps = {}; diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index d3274c589a..e0c532c021 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -104,6 +104,7 @@ export const mockClientMethodsServer = (): Partial