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/_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";
|
||||
|
|
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
|
||||
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 = {
|
||||
|
|
|
@ -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) {
|
||||
|
|
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 { 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)) {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
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,
|
||||
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();
|
||||
|
|
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 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(() => {
|
||||
|
|
|
@ -92,6 +92,7 @@ describe('<SessionManagerTab />', () => {
|
|||
getPushers: jest.fn(),
|
||||
setPusher: jest.fn(),
|
||||
setLocalNotificationSettings: jest.fn(),
|
||||
getVersions: jest.fn().mockResolvedValue({}),
|
||||
});
|
||||
|
||||
const defaultProps = {};
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue