diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 00661bd56b8..b2fcb0dd4f8 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 00000000000..390cf8311d0 --- /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 00000000000..62aef5df274 --- /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 00000000000..6c26cfe97ee --- /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 00000000000..7787141ad53 --- /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 00000000000..f95e618cc52 --- /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 6f10790811e..5d8fc2f952d 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 f32f7997fed..1b06fa06fe6 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 00000000000..20cdb37902e --- /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 c3b8cb0212a..f56ed85c87b 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 f4e4e55513d..b960e65a61e 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 d1fbb6ce5c1..49ca1bdbf29 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 3f078172b3f..3f69088f78e 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 9b6e09c772b..723b789ab01 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 00000000000..e3088fb3cb4 --- /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 a7baf139af3..81f6fb328a6 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 00000000000..c106b2f9a86 --- /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 00000000000..711f4710350 --- /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 00000000000..91fe73abf40 --- /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 00000000000..2cf0d24cc6c --- /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 bddb493463f..3497f2f161f 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 5bcb6cc36c9..7826b3cc809 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 d3274c589a8..e0c532c0216 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -104,6 +104,7 @@ export const mockClientMethodsServer = (): Partial