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 <travisr@matrix.org>

* Update src/components/views/auth/LoginWithQR.tsx

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/components/views/auth/LoginWithQR.tsx

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/components/views/auth/LoginWithQR.tsx

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/components/views/auth/LoginWithQR.tsx

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/components/views/auth/LoginWithQR.tsx

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update res/css/views/auth/_LoginWithQR.pcss

Co-authored-by: Kerry <kerrya@element.io>

* 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 <travisr@matrix.org>

* 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 <travisr@matrix.org>
Co-authored-by: Kerry <kerrya@element.io>
This commit is contained in:
Hugh Nimmo-Smith 2022-10-19 13:31:20 +01:00 committed by GitHub
parent e946674df3
commit 3c3df11d32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1638 additions and 12 deletions

View file

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

View file

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

View file

@ -0,0 +1,3 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 7H1M1 7L7 13M1 7L7 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

View file

@ -0,0 +1,11 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_721_35674)">
<path d="M5.33333 9.33313C5.33333 8.5998 5.93333 7.9998 6.66667 7.9998H28C28.7333 7.9998 29.3333 7.3998 29.3333 6.66646C29.3333 5.93313 28.7333 5.33313 28 5.33313H5.33333C3.86667 5.33313 2.66667 6.53313 2.66667 7.9998V22.6665H2C0.893333 22.6665 0 23.5598 0 24.6665C0 25.7731 0.893333 26.6665 2 26.6665H18.6667V22.6665H5.33333V9.33313ZM30.6667 10.6665H22.6667C21.9333 10.6665 21.3333 11.2665 21.3333 11.9998V25.3331C21.3333 26.0665 21.9333 26.6665 22.6667 26.6665H30.6667C31.4 26.6665 32 26.0665 32 25.3331V11.9998C32 11.2665 31.4 10.6665 30.6667 10.6665ZM29.3333 22.6665H24V13.3331H29.3333V22.6665Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_721_35674">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 891 B

View file

@ -0,0 +1,4 @@
<svg width="13" height="12" viewBox="0 0 13 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.16667 12V10.6667H8.5V12H7.16667ZM5.83333 10.6667V7.33333H7.16667V10.6667H5.83333ZM11.1667 8.66667V6H12.5V8.66667H11.1667ZM9.83333 6V4.66667H11.1667V6H9.83333ZM1.83333 7.33333V6H3.16667V7.33333H1.83333ZM0.5 6V4.66667H1.83333V6H0.5ZM6.5 1.33333V0H7.83333V1.33333H6.5ZM1.33333 3.16667H3.66667V0.833333H1.33333V3.16667ZM1 4C0.855556 4 0.736111 3.95278 0.641667 3.85833C0.547222 3.76389 0.5 3.64444 0.5 3.5V0.5C0.5 0.355556 0.547222 0.236111 0.641667 0.141667C0.736111 0.0472223 0.855556 0 1 0H4C4.14444 0 4.26389 0.0472223 4.35833 0.141667C4.45278 0.236111 4.5 0.355556 4.5 0.5V3.5C4.5 3.64444 4.45278 3.76389 4.35833 3.85833C4.26389 3.95278 4.14444 4 4 4H1ZM1.33333 11.1667H3.66667V8.83333H1.33333V11.1667ZM1 12C0.855556 12 0.736111 11.9528 0.641667 11.8583C0.547222 11.7639 0.5 11.6444 0.5 11.5V8.5C0.5 8.35556 0.547222 8.23611 0.641667 8.14167C0.736111 8.04722 0.855556 8 1 8H4C4.14444 8 4.26389 8.04722 4.35833 8.14167C4.45278 8.23611 4.5 8.35556 4.5 8.5V11.5C4.5 11.6444 4.45278 11.7639 4.35833 11.8583C4.26389 11.9528 4.14444 12 4 12H1ZM9.33333 3.16667H11.6667V0.833333H9.33333V3.16667ZM9 4C8.85556 4 8.73611 3.95278 8.64167 3.85833C8.54722 3.76389 8.5 3.64444 8.5 3.5V0.5C8.5 0.355556 8.54722 0.236111 8.64167 0.141667C8.73611 0.0472223 8.85556 0 9 0H12C12.1444 0 12.2639 0.0472223 12.3583 0.141667C12.4528 0.236111 12.5 0.355556 12.5 0.5V3.5C12.5 3.64444 12.4528 3.76389 12.3583 3.85833C12.2639 3.95278 12.1444 4 12 4H9ZM9.83333 12V10H8.5V8.66667H11.1667V10.6667H12.5V12H9.83333ZM7.16667 7.33333V6H9.83333V7.33333H7.16667ZM4.5 7.33333V6H3.16667V4.66667H7.16667V6H5.83333V7.33333H4.5ZM5.16667 4V1.33333H6.5V2.66667H7.83333V4H5.16667ZM2 2.5V1.5H3V2.5H2ZM2 10.5V9.5H3V10.5H2ZM10 2.5V1.5H11V2.5H10Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -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<IProps, IState> {
public constructor(props) {
super(props);
this.state = {
phase: Phase.Loading,
};
}
public componentDidMount(): void {
this.updateMode(this.props.mode).then(() => {});
}
public componentDidUpdate(prevProps: Readonly<IProps>): 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<void> => {
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<MSC3903ECDHPayload>({
onFailure: this.onFailure,
client: this.props.client,
});
const channel = new MSC3903ECDHv1RendezvousChannel<MSC3906RendezvousPayload>(
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 = () => <AccessibleButton
kind="primary_outline"
onClick={this.cancelClicked}
>
{ _t("Cancel") }
</AccessibleButton>;
private simpleSpinner = (description?: string): JSX.Element => {
return <div className="mx_LoginWithQR_spinner">
<div>
<Spinner />
{ description && <p>{ description }</p> }
</div>
</div>;
};
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 = <WarningBadge className="error" />;
backButton = false;
main = <p data-testid="cancellation-message">{ cancellationMessage }</p>;
buttons = <>
<AccessibleButton
kind="primary"
onClick={this.tryAgainClicked}
>
{ _t("Try again") }
</AccessibleButton>
{ this.cancelButton() }
</>;
break;
case Phase.Connected:
title = _t("Devices connected");
titleIcon = <DevicesIcon className="normal" />;
backButton = false;
main = <>
<p>{ _t("Check that the code below matches with your other device:") }</p>
<div className="mx_LoginWithQR_confirmationDigits">
{ this.state.confirmationDigits }
</div>
<div className="mx_LoginWithQR_confirmationAlert">
<div>
<InfoIcon />
</div>
<div>{ _t("By approving access for this device, it will have full access to your account.") }</div>
</div>
</>;
buttons = <>
<AccessibleButton
data-testid="decline-login-button"
kind="primary_outline"
onClick={this.declineClicked}
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
data-testid="approve-login-button"
kind="primary"
onClick={this.approveLogin}
>
{ _t("Approve") }
</AccessibleButton>
</>;
break;
case Phase.ShowingQR:
title =_t("Sign in with QR code");
if (this.state.rendezvous) {
const code = <div className="mx_LoginWithQR_qrWrapper">
<QRCode data={[{ data: Buffer.from(this.state.rendezvous.code), mode: 'byte' }]} className="mx_QRCode" />
</div>;
main = <>
<p>{ _t("Scan the QR code below with your device that's signed out.") }</p>
<ol>
<li>{ _t("Start at the sign in screen") }</li>
<li>{ _t("Select 'Scan QR code'") }</li>
<li>{ _t("Review and approve the sign in") }</li>
</ol>
{ 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 (
<div className="mx_LoginWithQR">
<div className={centreTitle ? "mx_LoginWithQR_centreTitle" : ""}>
{ backButton ?
<AccessibleButton
data-testid="back-button"
className="mx_LoginWithQR_BackButton"
onClick={this.onBackClick}
title="Back"
>
<BackButtonIcon />
</AccessibleButton>
: null }
<h1>{ titleIcon }{ title }</h1>
</div>
<div className="mx_LoginWithQR_main">
{ main }
</div>
<div className="mx_LoginWithQR_buttons">
{ buttons }
</div>
</div>
);
}
}

View file

@ -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<IProps, IState> {
constructor(props: IProps) {
export default class InteractiveAuthDialog extends React.Component<InteractiveAuthDialogProps, IState> {
constructor(props: InteractiveAuthDialogProps) {
super(props);
this.state = {

View file

@ -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<IProps, IState> {
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
private unmounted = false;
constructor(props: IProps) {
@ -52,15 +55,22 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
}
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<IProps, IState> {
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<IProps, IState> {
try {
await deleteDevicesWithInteractiveAuth(
MatrixClientPeg.get(),
this.context,
this.state.selectedDevices,
(success) => {
if (success) {
@ -208,7 +218,7 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
};
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<IProps, IState> {
return <Spinner />;
}
const myDeviceId = MatrixClientPeg.get().getDeviceId();
const myDeviceId = this.context.getDeviceId();
const myDevice = devices.find((device) => (device.device_id === myDeviceId));
if (!myDevice) {

View file

@ -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<IProps> {
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 <SettingsSubsection
heading={_t('Sign in with QR code')}
>
<div className="mx_LoginWithQRSection">
<p className="mx_SettingsTab_subsectionText">{
_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.")
}</p>
<AccessibleButton
onClick={this.props.onShowQr}
kind="primary"
>{ _t("Show QR code") }</AccessibleButton>
</div>
</SettingsSubsection>;
}
}

View file

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

View file

@ -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<string>;
showLoginWithQR: Mode | null;
versions?: IServerVersions;
}
export default class SecurityUserSettingsTab extends React.Component<IProps, IState> {
@ -88,6 +93,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
waitingUnignored: [],
managingInvites: false,
invitedRoomIds,
showLoginWithQR: null,
};
}
@ -102,6 +108,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(RoomEvent.MyMembership, this.onMyMembership);
MatrixClientPeg.get().getVersions().then(versions => this.setState({ versions }));
}
public componentWillUnmount(): void {
@ -251,6 +258,14 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
);
}
private onShowQRClicked = (): void => {
this.setState({ showLoginWithQR: Mode.Show });
};
private onLoginWithQRFinished = (): void => {
this.setState({ showLoginWithQR: null });
};
public render(): JSX.Element {
const secureBackup = (
<div className='mx_SettingsTab_section'>
@ -347,6 +362,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
}
const useNewSessionManager = SettingsStore.getValue("feature_new_device_manager");
const showQrCodeEnabled = SettingsStore.getValue("feature_qr_signin_reciprocate_show");
const devicesSection = useNewSessionManager
? null
: <>
@ -363,8 +379,20 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
</span>
<DevicesPanel />
</div>
{ showQrCodeEnabled ?
<LoginWithQRSection onShowQr={this.onShowQRClicked} versions={this.state.versions} />
: null
}
</>;
const client = MatrixClientPeg.get();
if (showQrCodeEnabled && this.state.showLoginWithQR) {
return <div className="mx_SettingsTab mx_SecurityUserSettingsTab">
<LoginWithQR onFinished={this.onLoginWithQRFinished} mode={this.state.showLoginWithQR} client={client} />
</div>;
}
return (
<div className="mx_SettingsTab mx_SecurityUserSettingsTab">
{ warning }

View file

@ -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<Mode | null>();
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 <LoginWithQR
mode={signInWithQrMode}
onFinished={onQrFinish}
client={matrixClient}
/>;
}
return <SettingsTab heading={_t('Sessions')}>
<SecurityRecommendations
devices={devices}
@ -222,6 +247,10 @@ const SessionManagerTab: React.FC = () => {
/>
</SettingsSubsection>
}
{ showQrCodeEnabled ?
<LoginWithQRSection onShowQr={onShowQrClicked} versions={clientVersions} />
: null
}
</SettingsTab>;
};

View file

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

View file

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

View file

@ -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<R, A> = (auth?: IAuthData, ...args: A[]) => Promise<UIAResponse<R>>;
export function wrapRequestWithDialog<R, A = any>(
requestFunction: FunctionWithUIA<R, A>,
opts: Omit<InteractiveAuthDialogProps, "makeRequest" | "onFinished">,
): ((...args: A[]) => Promise<R>) {
return async function(...args): Promise<R> {
return new Promise((resolve, reject) => {
const boundFunction = requestFunction.bind(opts.matrixClient) as FunctionWithUIA<R, A>;
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);
}
},
});
});
});
};
}

View file

@ -28,6 +28,7 @@ import {
mkPusher,
mockClientMethodsUser,
} from "../../../test-utils";
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
describe('<DevicesPanel />', () => {
const userId = '@alice:server.org';
@ -46,7 +47,10 @@ describe('<DevicesPanel />', () => {
setPusher: jest.fn(),
});
const getComponent = () => <DevicesPanel />;
const getComponent = () =>
<MatrixClientContext.Provider value={mockClient}>
<DevicesPanel />
</MatrixClientContext.Provider>;
beforeEach(() => {
jest.clearAllMocks();

View file

@ -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('<LoginWithQR />', () => {
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 }) =>
(<LoginWithQR {...defaultProps} {...props} />);
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);
});
});

View file

@ -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<string, boolean>): IServerVersions {
return {
versions: [],
unstable_features: unstableFeatures,
};
}
describe('<LoginWithQRSection />', () => {
beforeAll(() => {
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(makeClient());
});
const defaultProps = {
onShowQr: () => {},
versions: undefined,
};
const getComponent = (props = {}) =>
(<LoginWithQRSection {...defaultProps} {...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();
});
});
});

View file

@ -0,0 +1,367 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginWithQR /> approves login and waits for new device 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class=""
>
<div
class="mx_AccessibleButton mx_LoginWithQR_BackButton"
data-testid="back-button"
role="button"
tabindex="0"
title="Back"
>
<div />
</div>
<h1 />
</div>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_spinner"
>
<div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading..."
class="mx_Spinner_icon"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<p>
Waiting for device to sign in
</p>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> displays confirmation digits after connected to rendezvous 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class=""
>
<h1>
<div
class="normal"
/>
Devices connected
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p>
Check that the code below matches with your other device:
</p>
<div
class="mx_LoginWithQR_confirmationDigits"
>
mock-confirmation-digits
</div>
<div
class="mx_LoginWithQR_confirmationAlert"
>
<div>
<div />
</div>
<div>
By approving access for this device, it will have full access to your account.
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="decline-login-button"
role="button"
tabindex="0"
>
Cancel
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="approve-login-button"
role="button"
tabindex="0"
>
Approve
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> displays error when approving login fails 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class="mx_LoginWithQR_centreTitle"
>
<h1>
<div
class="error"
/>
Connection failed
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p
data-testid="cancellation-message"
>
An unexpected error occurred.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> displays qr code after it is created 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class=""
>
<div
class="mx_AccessibleButton mx_LoginWithQR_BackButton"
data-testid="back-button"
role="button"
tabindex="0"
title="Back"
>
<div />
</div>
<h1>
Sign in with QR code
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p>
Scan the QR code below with your device that's signed out.
</p>
<ol>
<li>
Start at the sign in screen
</li>
<li>
Select 'Scan QR code'
</li>
<li>
Review and approve the sign in
</li>
</ol>
<div
class="mx_LoginWithQR_qrWrapper"
>
<div
class="mx_QRCode mx_QRCode"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading..."
class="mx_Spinner_icon"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQR /> displays unknown error if connection to rendezvous fails 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class="mx_LoginWithQR_centreTitle"
>
<h1>
<div
class="error"
/>
Connection failed
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p
data-testid="cancellation-message"
>
An unexpected error occurred.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> no content in case of no support 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class="mx_LoginWithQR_centreTitle"
>
<h1>
<div
class="error"
/>
Connection failed
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p
data-testid="cancellation-message"
>
The homeserver doesn't support signing in another device.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> renders spinner while generating code 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class=""
>
<div
class="mx_AccessibleButton mx_LoginWithQR_BackButton"
data-testid="back-button"
role="button"
tabindex="0"
title="Back"
>
<div />
</div>
<h1 />
</div>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_spinner"
>
<div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading..."
class="mx_Spinner_icon"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;

View file

@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginWithQRSection /> should not render feature enabled 1`] = `<div />`;
exports[`<LoginWithQRSection /> should not render no support at all 1`] = `<div />`;
exports[`<LoginWithQRSection /> should not render only feature + MSC3882 enabled 1`] = `<div />`;
exports[`<LoginWithQRSection /> should render panel enabled by feature + MSC3882 + MSC3886 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Sign in with QR code
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_LoginWithQRSection"
>
<p
class="mx_SettingsTab_subsectionText"
>
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.
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Show QR code
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -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('<SecurityUserSettingsTab />', () => {
const defaultProps = {
closeSettingsFn: jest.fn(),
};
const getComponent = () => <SecurityUserSettingsTab {...defaultProps} />;
const userId = '@alice:server.org';
const deviceId = 'alices-device';
getMockClientWithEventEmitter({
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
@ -44,6 +44,11 @@ describe('<SecurityUserSettingsTab />', () => {
getIgnoredUsers: jest.fn(),
});
const getComponent = () =>
<MatrixClientContext.Provider value={mockClient}>
<SecurityUserSettingsTab {...defaultProps} />
</MatrixClientContext.Provider>;
const settingsValueSpy = jest.spyOn(SettingsStore, 'getValue');
beforeEach(() => {

View file

@ -92,6 +92,7 @@ describe('<SessionManagerTab />', () => {
getPushers: jest.fn(),
setPusher: jest.fn(),
setLocalNotificationSettings: jest.fn(),
getVersions: jest.fn().mockResolvedValue({}),
});
const defaultProps = {};

View file

@ -104,6 +104,7 @@ export const mockClientMethodsServer = (): Partial<Record<MethodKeysOf<MatrixCli
getCapabilities: jest.fn().mockReturnValue({}),
getClientWellKnown: jest.fn().mockReturnValue({}),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
getVersions: jest.fn().mockResolvedValue({}),
isFallbackICEServerAllowed: jest.fn(),
});