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:
parent
e946674df3
commit
3c3df11d32
23 changed files with 1638 additions and 12 deletions
|
@ -96,6 +96,7 @@
|
||||||
@import "./views/auth/_CountryDropdown.pcss";
|
@import "./views/auth/_CountryDropdown.pcss";
|
||||||
@import "./views/auth/_InteractiveAuthEntryComponents.pcss";
|
@import "./views/auth/_InteractiveAuthEntryComponents.pcss";
|
||||||
@import "./views/auth/_LanguageSelector.pcss";
|
@import "./views/auth/_LanguageSelector.pcss";
|
||||||
|
@import "./views/auth/_LoginWithQR.pcss";
|
||||||
@import "./views/auth/_PassphraseField.pcss";
|
@import "./views/auth/_PassphraseField.pcss";
|
||||||
@import "./views/auth/_Welcome.pcss";
|
@import "./views/auth/_Welcome.pcss";
|
||||||
@import "./views/avatars/_BaseAvatar.pcss";
|
@import "./views/avatars/_BaseAvatar.pcss";
|
||||||
|
|
171
res/css/views/auth/_LoginWithQR.pcss
Normal file
171
res/css/views/auth/_LoginWithQR.pcss
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
3
res/img/element-icons/back.svg
Normal file
3
res/img/element-icons/back.svg
Normal 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 |
11
res/img/element-icons/devices.svg
Normal file
11
res/img/element-icons/devices.svg
Normal 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 |
4
res/img/element-icons/qrcode.svg
Normal file
4
res/img/element-icons/qrcode.svg
Normal 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 |
396
src/components/views/auth/LoginWithQR.tsx
Normal file
396
src/components/views/auth/LoginWithQR.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,7 +38,7 @@ interface IDialogAesthetics {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProps extends IDialogProps {
|
export interface InteractiveAuthDialogProps extends IDialogProps {
|
||||||
// matrix client to use for UI auth requests
|
// matrix client to use for UI auth requests
|
||||||
matrixClient: MatrixClient;
|
matrixClient: MatrixClient;
|
||||||
|
|
||||||
|
@ -82,8 +82,8 @@ interface IState {
|
||||||
uiaStagePhase: number | string;
|
uiaStagePhase: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class InteractiveAuthDialog extends React.Component<IProps, IState> {
|
export default class InteractiveAuthDialog extends React.Component<InteractiveAuthDialogProps, IState> {
|
||||||
constructor(props: IProps) {
|
constructor(props: InteractiveAuthDialogProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
|
|
|
@ -19,13 +19,14 @@ import classNames from 'classnames';
|
||||||
import { IMyDevice } from "matrix-js-sdk/src/client";
|
import { IMyDevice } from "matrix-js-sdk/src/client";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning";
|
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 { _t } from '../../../languageHandler';
|
||||||
import DevicesPanelEntry from "./DevicesPanelEntry";
|
import DevicesPanelEntry from "./DevicesPanelEntry";
|
||||||
import Spinner from "../elements/Spinner";
|
import Spinner from "../elements/Spinner";
|
||||||
import AccessibleButton from "../elements/AccessibleButton";
|
import AccessibleButton from "../elements/AccessibleButton";
|
||||||
import { deleteDevicesWithInteractiveAuth } from './devices/deleteDevices';
|
import { deleteDevicesWithInteractiveAuth } from './devices/deleteDevices';
|
||||||
|
import MatrixClientContext from '../../../contexts/MatrixClientContext';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -40,6 +41,8 @@ interface IState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class DevicesPanel extends React.Component<IProps, IState> {
|
export default class DevicesPanel extends React.Component<IProps, IState> {
|
||||||
|
public static contextType = MatrixClientContext;
|
||||||
|
public context!: React.ContextType<typeof MatrixClientContext>;
|
||||||
private unmounted = false;
|
private unmounted = false;
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
|
@ -52,15 +55,22 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidMount(): void {
|
public componentDidMount(): void {
|
||||||
|
this.context.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||||
this.loadDevices();
|
this.loadDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
|
this.context.off(CryptoEvent.DevicesUpdated, this.onDevicesUpdated);
|
||||||
this.unmounted = true;
|
this.unmounted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onDevicesUpdated = (users: string[]) => {
|
||||||
|
if (!users.includes(this.context.getUserId())) return;
|
||||||
|
this.loadDevices();
|
||||||
|
};
|
||||||
|
|
||||||
private loadDevices(): void {
|
private loadDevices(): void {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = this.context;
|
||||||
cli.getDevices().then(
|
cli.getDevices().then(
|
||||||
(resp) => {
|
(resp) => {
|
||||||
if (this.unmounted) { return; }
|
if (this.unmounted) { return; }
|
||||||
|
@ -111,7 +121,7 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
private isDeviceVerified(device: IMyDevice): boolean | null {
|
private isDeviceVerified(device: IMyDevice): boolean | null {
|
||||||
try {
|
try {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = this.context;
|
||||||
const deviceInfo = cli.getStoredDevice(cli.getUserId(), device.device_id);
|
const deviceInfo = cli.getStoredDevice(cli.getUserId(), device.device_id);
|
||||||
return this.state.crossSigningInfo.checkDeviceTrust(
|
return this.state.crossSigningInfo.checkDeviceTrust(
|
||||||
this.state.crossSigningInfo,
|
this.state.crossSigningInfo,
|
||||||
|
@ -184,7 +194,7 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteDevicesWithInteractiveAuth(
|
await deleteDevicesWithInteractiveAuth(
|
||||||
MatrixClientPeg.get(),
|
this.context,
|
||||||
this.state.selectedDevices,
|
this.state.selectedDevices,
|
||||||
(success) => {
|
(success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
|
@ -208,7 +218,7 @@ export default class DevicesPanel extends React.Component<IProps, IState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private renderDevice = (device: IMyDevice): JSX.Element => {
|
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 myDevice = this.state.devices.find((device) => (device.device_id === myDeviceId));
|
||||||
|
|
||||||
const isOwnDevice = 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 />;
|
return <Spinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const myDeviceId = MatrixClientPeg.get().getDeviceId();
|
const myDeviceId = this.context.getDeviceId();
|
||||||
const myDevice = devices.find((device) => (device.device_id === myDeviceId));
|
const myDevice = devices.find((device) => (device.device_id === myDeviceId));
|
||||||
|
|
||||||
if (!myDevice) {
|
if (!myDevice) {
|
||||||
|
|
63
src/components/views/settings/devices/LoginWithQRSection.tsx
Normal file
63
src/components/views/settings/devices/LoginWithQRSection.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/reque
|
||||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
|
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
|
||||||
|
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
|
||||||
|
|
||||||
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
|
@ -179,6 +180,12 @@ export const useOwnDevices = (): DevicesState => {
|
||||||
refreshDevices();
|
refreshDevices();
|
||||||
}, [refreshDevices]);
|
}, [refreshDevices]);
|
||||||
|
|
||||||
|
useEventEmitter(matrixClient, CryptoEvent.DevicesUpdated, (users: string[]): void => {
|
||||||
|
if (users.includes(userId)) {
|
||||||
|
refreshDevices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => {
|
useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => {
|
||||||
const type = event.getType();
|
const type = event.getType();
|
||||||
if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
|
if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
|
||||||
|
|
|
@ -38,6 +38,9 @@ import InlineSpinner from "../../../elements/InlineSpinner";
|
||||||
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
|
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
|
||||||
import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog";
|
import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog";
|
||||||
import { privateShouldBeEncrypted } from "../../../../../utils/rooms";
|
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 {
|
interface IIgnoredUserProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
@ -72,6 +75,8 @@ interface IState {
|
||||||
waitingUnignored: string[];
|
waitingUnignored: string[];
|
||||||
managingInvites: boolean;
|
managingInvites: boolean;
|
||||||
invitedRoomIds: Set<string>;
|
invitedRoomIds: Set<string>;
|
||||||
|
showLoginWithQR: Mode | null;
|
||||||
|
versions?: IServerVersions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class SecurityUserSettingsTab extends React.Component<IProps, IState> {
|
export default class SecurityUserSettingsTab extends React.Component<IProps, IState> {
|
||||||
|
@ -88,6 +93,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
||||||
waitingUnignored: [],
|
waitingUnignored: [],
|
||||||
managingInvites: false,
|
managingInvites: false,
|
||||||
invitedRoomIds,
|
invitedRoomIds,
|
||||||
|
showLoginWithQR: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,6 +108,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
||||||
public componentDidMount(): void {
|
public componentDidMount(): void {
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
MatrixClientPeg.get().on(RoomEvent.MyMembership, this.onMyMembership);
|
MatrixClientPeg.get().on(RoomEvent.MyMembership, this.onMyMembership);
|
||||||
|
MatrixClientPeg.get().getVersions().then(versions => this.setState({ versions }));
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
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 {
|
public render(): JSX.Element {
|
||||||
const secureBackup = (
|
const secureBackup = (
|
||||||
<div className='mx_SettingsTab_section'>
|
<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 useNewSessionManager = SettingsStore.getValue("feature_new_device_manager");
|
||||||
|
const showQrCodeEnabled = SettingsStore.getValue("feature_qr_signin_reciprocate_show");
|
||||||
const devicesSection = useNewSessionManager
|
const devicesSection = useNewSessionManager
|
||||||
? null
|
? null
|
||||||
: <>
|
: <>
|
||||||
|
@ -363,8 +379,20 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
||||||
</span>
|
</span>
|
||||||
<DevicesPanel />
|
<DevicesPanel />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="mx_SettingsTab mx_SecurityUserSettingsTab">
|
<div className="mx_SettingsTab mx_SecurityUserSettingsTab">
|
||||||
{ warning }
|
{ warning }
|
||||||
|
|
|
@ -32,6 +32,10 @@ import SecurityRecommendations from '../../devices/SecurityRecommendations';
|
||||||
import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types';
|
import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types';
|
||||||
import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices';
|
import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices';
|
||||||
import SettingsTab from '../SettingsTab';
|
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 = (
|
const useSignOut = (
|
||||||
matrixClient: MatrixClient,
|
matrixClient: MatrixClient,
|
||||||
|
@ -104,6 +108,7 @@ const SessionManagerTab: React.FC = () => {
|
||||||
const matrixClient = useContext(MatrixClientContext);
|
const matrixClient = useContext(MatrixClientContext);
|
||||||
const userId = matrixClient.getUserId();
|
const userId = matrixClient.getUserId();
|
||||||
const currentUserMember = userId && matrixClient.getUser(userId) || undefined;
|
const currentUserMember = userId && matrixClient.getUser(userId) || undefined;
|
||||||
|
const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]);
|
||||||
|
|
||||||
const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => {
|
const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => {
|
||||||
if (expandedDeviceIds.includes(deviceId)) {
|
if (expandedDeviceIds.includes(deviceId)) {
|
||||||
|
@ -175,6 +180,26 @@ const SessionManagerTab: React.FC = () => {
|
||||||
onSignOutOtherDevices(Object.keys(otherDevices));
|
onSignOutOtherDevices(Object.keys(otherDevices));
|
||||||
}: undefined;
|
}: 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')}>
|
return <SettingsTab heading={_t('Sessions')}>
|
||||||
<SecurityRecommendations
|
<SecurityRecommendations
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
@ -222,6 +247,10 @@ const SessionManagerTab: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</SettingsSubsection>
|
</SettingsSubsection>
|
||||||
}
|
}
|
||||||
|
{ showQrCodeEnabled ?
|
||||||
|
<LoginWithQRSection onShowQr={onShowQrClicked} versions={clientVersions} />
|
||||||
|
: null
|
||||||
|
}
|
||||||
</SettingsTab>;
|
</SettingsTab>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -935,6 +935,7 @@
|
||||||
"New session manager": "New session manager",
|
"New session manager": "New session manager",
|
||||||
"Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.",
|
"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.",
|
"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",
|
"Font size": "Font size",
|
||||||
"Use custom size": "Use custom size",
|
"Use custom size": "Use custom size",
|
||||||
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
|
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
|
||||||
|
@ -1788,6 +1789,9 @@
|
||||||
"Filter devices": "Filter devices",
|
"Filter devices": "Filter devices",
|
||||||
"Show": "Show",
|
"Show": "Show",
|
||||||
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected",
|
"%(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",
|
"Security recommendations": "Security recommendations",
|
||||||
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
|
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
|
||||||
"View all": "View all",
|
"View all": "View all",
|
||||||
|
@ -3181,6 +3185,26 @@
|
||||||
"Submit": "Submit",
|
"Submit": "Submit",
|
||||||
"Something went wrong in confirming your identity. Cancel and try again.": "Something went wrong in confirming your identity. Cancel and try again.",
|
"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",
|
"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",
|
"Enter password": "Enter password",
|
||||||
"Nice, strong password!": "Nice, strong password!",
|
"Nice, strong password!": "Nice, strong password!",
|
||||||
"Password is allowed, but unsafe": "Password is allowed, but unsafe",
|
"Password is allowed, but unsafe": "Password is allowed, but unsafe",
|
||||||
|
|
|
@ -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": {
|
"baseFontSize": {
|
||||||
displayName: _td("Font size"),
|
displayName: _td("Font size"),
|
||||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||||
|
|
55
src/utils/UserInteractiveAuth.ts
Normal file
55
src/utils/UserInteractiveAuth.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -28,6 +28,7 @@ import {
|
||||||
mkPusher,
|
mkPusher,
|
||||||
mockClientMethodsUser,
|
mockClientMethodsUser,
|
||||||
} from "../../../test-utils";
|
} from "../../../test-utils";
|
||||||
|
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
|
||||||
|
|
||||||
describe('<DevicesPanel />', () => {
|
describe('<DevicesPanel />', () => {
|
||||||
const userId = '@alice:server.org';
|
const userId = '@alice:server.org';
|
||||||
|
@ -46,7 +47,10 @@ describe('<DevicesPanel />', () => {
|
||||||
setPusher: jest.fn(),
|
setPusher: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getComponent = () => <DevicesPanel />;
|
const getComponent = () =>
|
||||||
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<DevicesPanel />
|
||||||
|
</MatrixClientContext.Provider>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
297
test/components/views/settings/devices/LoginWithQR-test.tsx
Normal file
297
test/components/views/settings/devices/LoginWithQR-test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
`;
|
|
@ -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>
|
||||||
|
`;
|
|
@ -17,6 +17,7 @@ import { render } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import SecurityUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/SecurityUserSettingsTab";
|
import SecurityUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/SecurityUserSettingsTab";
|
||||||
|
import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext';
|
||||||
import SettingsStore from '../../../../../../src/settings/SettingsStore';
|
import SettingsStore from '../../../../../../src/settings/SettingsStore';
|
||||||
import {
|
import {
|
||||||
getMockClientWithEventEmitter,
|
getMockClientWithEventEmitter,
|
||||||
|
@ -31,11 +32,10 @@ describe('<SecurityUserSettingsTab />', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
closeSettingsFn: jest.fn(),
|
closeSettingsFn: jest.fn(),
|
||||||
};
|
};
|
||||||
const getComponent = () => <SecurityUserSettingsTab {...defaultProps} />;
|
|
||||||
|
|
||||||
const userId = '@alice:server.org';
|
const userId = '@alice:server.org';
|
||||||
const deviceId = 'alices-device';
|
const deviceId = 'alices-device';
|
||||||
getMockClientWithEventEmitter({
|
const mockClient = getMockClientWithEventEmitter({
|
||||||
...mockClientMethodsUser(userId),
|
...mockClientMethodsUser(userId),
|
||||||
...mockClientMethodsServer(),
|
...mockClientMethodsServer(),
|
||||||
...mockClientMethodsDevice(deviceId),
|
...mockClientMethodsDevice(deviceId),
|
||||||
|
@ -44,6 +44,11 @@ describe('<SecurityUserSettingsTab />', () => {
|
||||||
getIgnoredUsers: jest.fn(),
|
getIgnoredUsers: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getComponent = () =>
|
||||||
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<SecurityUserSettingsTab {...defaultProps} />
|
||||||
|
</MatrixClientContext.Provider>;
|
||||||
|
|
||||||
const settingsValueSpy = jest.spyOn(SettingsStore, 'getValue');
|
const settingsValueSpy = jest.spyOn(SettingsStore, 'getValue');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
|
@ -92,6 +92,7 @@ describe('<SessionManagerTab />', () => {
|
||||||
getPushers: jest.fn(),
|
getPushers: jest.fn(),
|
||||||
setPusher: jest.fn(),
|
setPusher: jest.fn(),
|
||||||
setLocalNotificationSettings: jest.fn(),
|
setLocalNotificationSettings: jest.fn(),
|
||||||
|
getVersions: jest.fn().mockResolvedValue({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultProps = {};
|
const defaultProps = {};
|
||||||
|
|
|
@ -104,6 +104,7 @@ export const mockClientMethodsServer = (): Partial<Record<MethodKeysOf<MatrixCli
|
||||||
getCapabilities: jest.fn().mockReturnValue({}),
|
getCapabilities: jest.fn().mockReturnValue({}),
|
||||||
getClientWellKnown: jest.fn().mockReturnValue({}),
|
getClientWellKnown: jest.fn().mockReturnValue({}),
|
||||||
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
|
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
|
||||||
|
getVersions: jest.fn().mockResolvedValue({}),
|
||||||
isFallbackICEServerAllowed: jest.fn(),
|
isFallbackICEServerAllowed: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue