MSC4108 support OIDC QR code login (#12370)
Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
parent
ca7760789b
commit
1677ed1be0
24 changed files with 1558 additions and 733 deletions
|
@ -98,8 +98,6 @@ module.exports = {
|
||||||
"!matrix-js-sdk/src/secret-storage",
|
"!matrix-js-sdk/src/secret-storage",
|
||||||
"!matrix-js-sdk/src/room-hierarchy",
|
"!matrix-js-sdk/src/room-hierarchy",
|
||||||
"!matrix-js-sdk/src/rendezvous",
|
"!matrix-js-sdk/src/rendezvous",
|
||||||
"!matrix-js-sdk/src/rendezvous/transports",
|
|
||||||
"!matrix-js-sdk/src/rendezvous/channels",
|
|
||||||
"!matrix-js-sdk/src/indexeddb-worker",
|
"!matrix-js-sdk/src/indexeddb-worker",
|
||||||
"!matrix-js-sdk/src/pushprocessor",
|
"!matrix-js-sdk/src/pushprocessor",
|
||||||
"!matrix-js-sdk/src/extensible_events_v1",
|
"!matrix-js-sdk/src/extensible_events_v1",
|
||||||
|
|
2
.github/workflows/static_analysis.yaml
vendored
2
.github/workflows/static_analysis.yaml
vendored
|
@ -27,7 +27,7 @@ jobs:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
|
|
||||||
- name: Install Deps
|
- name: Install Deps
|
||||||
run: "./scripts/ci/install-deps.sh --ignore-scripts"
|
run: "./scripts/ci/install-deps.sh"
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: "yarn run lint:types"
|
run: "yarn run lint:types"
|
||||||
|
|
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
@ -47,7 +47,7 @@ jobs:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
|
|
||||||
- name: Install Deps
|
- name: Install Deps
|
||||||
run: "./scripts/ci/install-deps.sh --ignore-scripts"
|
run: "./scripts/ci/install-deps.sh"
|
||||||
env:
|
env:
|
||||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||||
|
|
||||||
|
|
|
@ -207,6 +207,10 @@ limitations under the License.
|
||||||
.mx_UserMenu_iconSignOut::before {
|
.mx_UserMenu_iconSignOut::before {
|
||||||
mask-image: url("$(res)/img/element-icons/leave.svg");
|
mask-image: url("$(res)/img/element-icons/leave.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_UserMenu_iconQr::before {
|
||||||
|
mask-image: url("@vector-im/compound-design-tokens/icons/qr-code.svg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_UserMenu_CustomStatusSection {
|
.mx_UserMenu_CustomStatusSection {
|
||||||
|
|
|
@ -764,7 +764,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
const tabPayload = payload as OpenToTabPayload;
|
const tabPayload = payload as OpenToTabPayload;
|
||||||
Modal.createDialog(
|
Modal.createDialog(
|
||||||
UserSettingsDialog,
|
UserSettingsDialog,
|
||||||
{ initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores },
|
{ ...payload.props, initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores },
|
||||||
/*className=*/ undefined,
|
/*className=*/ undefined,
|
||||||
/*isPriority=*/ false,
|
/*isPriority=*/ false,
|
||||||
/*isStatic=*/ true,
|
/*isStatic=*/ true,
|
||||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createRef, ReactNode } from "react";
|
import React, { createRef, ReactNode } from "react";
|
||||||
import { Room } from "matrix-js-sdk/src/matrix";
|
import { discoverAndValidateOIDCIssuerWellKnown, Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
|
@ -52,6 +52,8 @@ import { Icon as LiveIcon } from "../../../res/img/compound/live-8px.svg";
|
||||||
import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast";
|
import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast";
|
||||||
import { SDKContext } from "../../contexts/SDKContext";
|
import { SDKContext } from "../../contexts/SDKContext";
|
||||||
import { shouldShowFeedback } from "../../utils/Feedback";
|
import { shouldShowFeedback } from "../../utils/Feedback";
|
||||||
|
import { shouldShowQr } from "../views/settings/devices/LoginWithQRSection";
|
||||||
|
import { Features } from "../../settings/Settings";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
isPanelCollapsed: boolean;
|
isPanelCollapsed: boolean;
|
||||||
|
@ -66,6 +68,8 @@ interface IState {
|
||||||
isHighContrast: boolean;
|
isHighContrast: boolean;
|
||||||
selectedSpace?: Room | null;
|
selectedSpace?: Room | null;
|
||||||
showLiveAvatarAddon: boolean;
|
showLiveAvatarAddon: boolean;
|
||||||
|
showQrLogin: boolean;
|
||||||
|
supportsQrLogin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toRightOf = (rect: PartialDOMRect): MenuProps => {
|
const toRightOf = (rect: PartialDOMRect): MenuProps => {
|
||||||
|
@ -103,6 +107,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||||
isHighContrast: this.isUserOnHighContrastTheme(),
|
isHighContrast: this.isUserOnHighContrastTheme(),
|
||||||
selectedSpace: SpaceStore.instance.activeSpaceRoom,
|
selectedSpace: SpaceStore.instance.activeSpaceRoom,
|
||||||
showLiveAvatarAddon: this.context.voiceBroadcastRecordingsStore.hasCurrent(),
|
showLiveAvatarAddon: this.context.voiceBroadcastRecordingsStore.hasCurrent(),
|
||||||
|
showQrLogin: false,
|
||||||
|
supportsQrLogin: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||||
|
@ -126,6 +132,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||||
);
|
);
|
||||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||||
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||||
|
this.checkQrLoginSupport();
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
|
@ -140,6 +147,29 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private checkQrLoginSupport = async (): Promise<void> => {
|
||||||
|
if (!this.context.client || !SettingsStore.getValue(Features.OidcNativeFlow)) return;
|
||||||
|
|
||||||
|
const { issuer } = await this.context.client.getAuthIssuer().catch(() => ({ issuer: undefined }));
|
||||||
|
if (issuer) {
|
||||||
|
const [oidcClientConfig, versions, wellKnown, isCrossSigningReady] = await Promise.all([
|
||||||
|
discoverAndValidateOIDCIssuerWellKnown(issuer),
|
||||||
|
this.context.client.getVersions(),
|
||||||
|
this.context.client.waitForClientWellKnown(),
|
||||||
|
this.context.client.getCrypto()?.isCrossSigningReady(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const supportsQrLogin = shouldShowQr(
|
||||||
|
this.context.client,
|
||||||
|
!!isCrossSigningReady,
|
||||||
|
oidcClientConfig,
|
||||||
|
versions,
|
||||||
|
wellKnown,
|
||||||
|
);
|
||||||
|
this.setState({ supportsQrLogin, showQrLogin: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private isUserOnDarkTheme(): boolean {
|
private isUserOnDarkTheme(): boolean {
|
||||||
if (SettingsStore.getValue("use_system_theme")) {
|
if (SettingsStore.getValue("use_system_theme")) {
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
@ -237,11 +267,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||||
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
|
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
|
||||||
};
|
};
|
||||||
|
|
||||||
private onSettingsOpen = (ev: ButtonEvent, tabId?: string): void => {
|
private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record<string, any>): void => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId };
|
const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId, props };
|
||||||
defaultDispatcher.dispatch(payload);
|
defaultDispatcher.dispatch(payload);
|
||||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||||
};
|
};
|
||||||
|
@ -363,9 +393,33 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let linkNewDeviceButton: JSX.Element | undefined;
|
||||||
|
if (this.state.showQrLogin) {
|
||||||
|
const extraProps: Omit<
|
||||||
|
React.ComponentProps<typeof IconizedContextMenuOption>,
|
||||||
|
"iconClassname" | "label" | "onClick"
|
||||||
|
> = {};
|
||||||
|
if (!this.state.supportsQrLogin) {
|
||||||
|
extraProps.disabled = true;
|
||||||
|
extraProps.title = _t("user_menu|link_new_device_not_supported");
|
||||||
|
extraProps.caption = _t("user_menu|link_new_device_not_supported_caption");
|
||||||
|
extraProps.placement = "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
linkNewDeviceButton = (
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
{...extraProps}
|
||||||
|
iconClassName="mx_UserMenu_iconQr"
|
||||||
|
label={_t("user_menu|link_new_device")}
|
||||||
|
onClick={(e) => this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let primaryOptionList = (
|
let primaryOptionList = (
|
||||||
<IconizedContextMenuOptionList>
|
<IconizedContextMenuOptionList>
|
||||||
{homeButton}
|
{homeButton}
|
||||||
|
{linkNewDeviceButton}
|
||||||
<IconizedContextMenuOption
|
<IconizedContextMenuOption
|
||||||
iconClassName="mx_UserMenu_iconBell"
|
iconClassName="mx_UserMenu_iconBell"
|
||||||
label={_t("notifications|enable_prompt_toast_title")}
|
label={_t("notifications|enable_prompt_toast_title")}
|
||||||
|
|
|
@ -27,17 +27,21 @@ export enum Mode {
|
||||||
export enum Phase {
|
export enum Phase {
|
||||||
Loading,
|
Loading,
|
||||||
ShowingQR,
|
ShowingQR,
|
||||||
Connecting,
|
// The following are specific to MSC4108
|
||||||
Connected,
|
OutOfBandConfirmation,
|
||||||
WaitingForDevice,
|
WaitingForDevice,
|
||||||
Verifying,
|
Verifying,
|
||||||
Error,
|
Error,
|
||||||
|
/**
|
||||||
|
* @deprecated the MSC3906 implementation is deprecated in favour of MSC4108.
|
||||||
|
*/
|
||||||
|
LegacyConnected,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Click {
|
export enum Click {
|
||||||
Cancel,
|
Cancel,
|
||||||
Decline,
|
Decline,
|
||||||
Approve,
|
Approve,
|
||||||
TryAgain,
|
|
||||||
Back,
|
Back,
|
||||||
|
ShowQr,
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,39 +16,61 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
MSC3906Rendezvous,
|
ClientRendezvousFailureReason,
|
||||||
MSC3906RendezvousPayload,
|
|
||||||
LegacyRendezvousFailureReason,
|
LegacyRendezvousFailureReason,
|
||||||
|
MSC3886SimpleHttpRendezvousTransport,
|
||||||
|
MSC3903ECDHPayload,
|
||||||
|
MSC3903ECDHv2RendezvousChannel,
|
||||||
|
MSC3906Rendezvous,
|
||||||
|
MSC4108FailureReason,
|
||||||
|
MSC4108RendezvousSession,
|
||||||
|
MSC4108SecureChannel,
|
||||||
|
MSC4108SignInWithQR,
|
||||||
|
RendezvousError,
|
||||||
|
RendezvousFailureReason,
|
||||||
|
RendezvousIntent,
|
||||||
} from "matrix-js-sdk/src/rendezvous";
|
} from "matrix-js-sdk/src/rendezvous";
|
||||||
import { MSC3886SimpleHttpRendezvousTransport } from "matrix-js-sdk/src/rendezvous/transports";
|
|
||||||
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "matrix-js-sdk/src/rendezvous/channels";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { HTTPError, MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { HTTPError, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import { wrapRequestWithDialog } from "../../../utils/UserInteractiveAuth";
|
|
||||||
import LoginWithQRFlow from "./LoginWithQRFlow";
|
|
||||||
import { Click, Mode, Phase } from "./LoginWithQR-types";
|
import { Click, Mode, Phase } from "./LoginWithQR-types";
|
||||||
|
import LoginWithQRFlow from "./LoginWithQRFlow";
|
||||||
|
import { wrapRequestWithDialog } from "../../../utils/UserInteractiveAuth";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
|
legacy: boolean;
|
||||||
onFinished(...args: any): void;
|
onFinished(...args: any): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
phase: Phase;
|
phase: Phase;
|
||||||
rendezvous?: MSC3906Rendezvous;
|
rendezvous?: MSC3906Rendezvous | MSC4108SignInWithQR;
|
||||||
confirmationDigits?: string;
|
|
||||||
failureReason?: FailureReason;
|
|
||||||
mediaPermissionError?: boolean;
|
mediaPermissionError?: boolean;
|
||||||
|
|
||||||
|
// MSC3906
|
||||||
|
confirmationDigits?: string;
|
||||||
|
|
||||||
|
// MSC4108
|
||||||
|
verificationUri?: string;
|
||||||
|
userCode?: string;
|
||||||
|
checkCode?: string;
|
||||||
|
failureReason?: FailureReason;
|
||||||
|
lastScannedCode?: Buffer;
|
||||||
|
homeserverBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LoginWithQRFailureReason {
|
export enum LoginWithQRFailureReason {
|
||||||
|
/**
|
||||||
|
* @deprecated the MSC3906 implementation is deprecated in favour of MSC4108.
|
||||||
|
*/
|
||||||
RateLimited = "rate_limited",
|
RateLimited = "rate_limited",
|
||||||
|
CheckCodeMismatch = "check_code_mismatch",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FailureReason = LegacyRendezvousFailureReason | LoginWithQRFailureReason;
|
export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason;
|
||||||
|
|
||||||
// n.b MSC3886/MSC3903/MSC3906 that this is based on are now closed.
|
// n.b MSC3886/MSC3903/MSC3906 that this is based on are now closed.
|
||||||
// However, we want to keep this implementation around for some time.
|
// However, we want to keep this implementation around for some time.
|
||||||
|
@ -62,6 +84,8 @@ export type FailureReason = LegacyRendezvousFailureReason | LoginWithQRFailureRe
|
||||||
* This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906
|
* 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> {
|
export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||||
|
private finished = false;
|
||||||
|
|
||||||
public constructor(props: IProps) {
|
public constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
@ -70,6 +94,10 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get ourIntent(): RendezvousIntent {
|
||||||
|
return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
|
||||||
|
}
|
||||||
|
|
||||||
public componentDidMount(): void {
|
public componentDidMount(): void {
|
||||||
this.updateMode(this.props.mode).then(() => {});
|
this.updateMode(this.props.mode).then(() => {});
|
||||||
}
|
}
|
||||||
|
@ -85,27 +113,36 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||||
if (this.state.rendezvous) {
|
if (this.state.rendezvous) {
|
||||||
const rendezvous = this.state.rendezvous;
|
const rendezvous = this.state.rendezvous;
|
||||||
rendezvous.onFailure = undefined;
|
rendezvous.onFailure = undefined;
|
||||||
await rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled);
|
if (rendezvous instanceof MSC3906Rendezvous) {
|
||||||
|
await rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled);
|
||||||
|
}
|
||||||
this.setState({ rendezvous: undefined });
|
this.setState({ rendezvous: undefined });
|
||||||
}
|
}
|
||||||
if (mode === Mode.Show) {
|
if (mode === Mode.Show) {
|
||||||
await this.generateCode();
|
await this.generateAndShowCode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
if (this.state.rendezvous) {
|
if (this.state.rendezvous && !this.finished) {
|
||||||
// eslint-disable-next-line react/no-direct-mutation-state
|
// eslint-disable-next-line react/no-direct-mutation-state
|
||||||
this.state.rendezvous.onFailure = undefined;
|
this.state.rendezvous.onFailure = undefined;
|
||||||
// calling cancel will call close() as well to clean up the resources
|
// calling cancel will call close() as well to clean up the resources
|
||||||
this.state.rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled).then(() => {});
|
if (this.state.rendezvous instanceof MSC3906Rendezvous) {
|
||||||
|
this.state.rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled);
|
||||||
|
} else {
|
||||||
|
this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private approveLogin = async (): Promise<void> => {
|
private async legacyApproveLogin(): Promise<void> {
|
||||||
if (!this.state.rendezvous) {
|
if (!(this.state.rendezvous instanceof MSC3906Rendezvous)) {
|
||||||
throw new Error("Rendezvous not found");
|
throw new Error("Rendezvous not found");
|
||||||
}
|
}
|
||||||
|
if (!this.props.client) {
|
||||||
|
throw new Error("No client to approve login with");
|
||||||
|
}
|
||||||
this.setState({ phase: Phase.Loading });
|
this.setState({ phase: Phase.Loading });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -125,7 +162,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
if (!this.props.client.getCrypto()) {
|
if (!this.props.client.getCrypto()) {
|
||||||
// no E2EE to set up
|
// no E2EE to set up
|
||||||
this.props.onFinished(true);
|
this.onFinished(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState({ phase: Phase.Verifying });
|
this.setState({ phase: Phase.Verifying });
|
||||||
|
@ -136,7 +173,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||||
} finally {
|
} finally {
|
||||||
this.setState({ rendezvous: undefined });
|
this.setState({ rendezvous: undefined });
|
||||||
}
|
}
|
||||||
this.props.onFinished(true);
|
this.onFinished(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error whilst approving sign in", e);
|
logger.error("Error whilst approving sign in", e);
|
||||||
if (e instanceof HTTPError && e.httpStatus === 429) {
|
if (e instanceof HTTPError && e.httpStatus === 429) {
|
||||||
|
@ -144,27 +181,38 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||||
this.setState({ phase: Phase.Error, failureReason: LoginWithQRFailureReason.RateLimited });
|
this.setState({ phase: Phase.Error, failureReason: LoginWithQRFailureReason.RateLimited });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown });
|
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
private generateCode = async (): Promise<void> => {
|
private onFinished(success: boolean): void {
|
||||||
let rendezvous: MSC3906Rendezvous;
|
this.finished = true;
|
||||||
|
this.props.onFinished(success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateAndShowCode = async (): Promise<void> => {
|
||||||
|
let rendezvous: MSC4108SignInWithQR | MSC3906Rendezvous;
|
||||||
try {
|
try {
|
||||||
const fallbackRzServer = this.props.client.getClientWellKnown()?.["io.element.rendezvous"]?.server;
|
const fallbackRzServer = this.props.client?.getClientWellKnown()?.["io.element.rendezvous"]?.server;
|
||||||
const transport = new MSC3886SimpleHttpRendezvousTransport<MSC3903ECDHPayload>({
|
|
||||||
onFailure: this.onFailure,
|
|
||||||
client: this.props.client,
|
|
||||||
fallbackRzServer,
|
|
||||||
});
|
|
||||||
|
|
||||||
const channel = new MSC3903ECDHv2RendezvousChannel<MSC3906RendezvousPayload>(
|
if (this.props.legacy) {
|
||||||
transport,
|
const transport = new MSC3886SimpleHttpRendezvousTransport<MSC3903ECDHPayload>({
|
||||||
undefined,
|
onFailure: this.onFailure,
|
||||||
this.onFailure,
|
client: this.props.client,
|
||||||
);
|
fallbackRzServer,
|
||||||
|
});
|
||||||
rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure);
|
const channel = new MSC3903ECDHv2RendezvousChannel(transport, undefined, this.onFailure);
|
||||||
|
rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure);
|
||||||
|
} else {
|
||||||
|
const transport = new MSC4108RendezvousSession({
|
||||||
|
onFailure: this.onFailure,
|
||||||
|
client: this.props.client,
|
||||||
|
fallbackRzServer,
|
||||||
|
});
|
||||||
|
await transport.send("");
|
||||||
|
const channel = new MSC4108SecureChannel(transport, undefined, this.onFailure);
|
||||||
|
rendezvous = new MSC4108SignInWithQR(channel, false, this.props.client, this.onFailure);
|
||||||
|
}
|
||||||
|
|
||||||
await rendezvous.generateCode();
|
await rendezvous.generateCode();
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -174,23 +222,84 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error whilst generating QR code", e);
|
logger.error("Error whilst generating QR code", e);
|
||||||
this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport });
|
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const confirmationDigits = await rendezvous.startAfterShowingCode();
|
if (rendezvous instanceof MSC3906Rendezvous) {
|
||||||
this.setState({ phase: Phase.Connected, confirmationDigits });
|
const confirmationDigits = await rendezvous.startAfterShowingCode();
|
||||||
} catch (e) {
|
this.setState({ phase: Phase.LegacyConnected, confirmationDigits });
|
||||||
logger.error("Error whilst doing QR login", e);
|
} else if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
|
||||||
// only set to error phase if it hasn't already been set by onFailure or similar
|
// MSC4108-Flow: NewScanned
|
||||||
if (this.state.phase !== Phase.Error) {
|
await rendezvous.negotiateProtocols();
|
||||||
this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown });
|
const { verificationUri } = await rendezvous.deviceAuthorizationGrant();
|
||||||
|
this.setState({
|
||||||
|
phase: Phase.OutOfBandConfirmation,
|
||||||
|
verificationUri,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// we ask the user to confirm that the channel is secure
|
||||||
|
} catch (e: RendezvousError | unknown) {
|
||||||
|
logger.error("Error whilst approving login", e);
|
||||||
|
if (rendezvous instanceof MSC3906Rendezvous) {
|
||||||
|
// 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: LegacyRendezvousFailureReason.Unknown });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await rendezvous?.cancel(
|
||||||
|
e instanceof RendezvousError
|
||||||
|
? (e.code as MSC4108FailureReason)
|
||||||
|
: ClientRendezvousFailureReason.Unknown,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onFailure = (reason: LegacyRendezvousFailureReason): void => {
|
private approveLogin = async (checkCode: string | undefined): Promise<void> => {
|
||||||
|
if (!(this.state.rendezvous instanceof MSC4108SignInWithQR)) {
|
||||||
|
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
|
||||||
|
throw new Error("Rendezvous not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.lastScannedCode && this.state.rendezvous?.checkCode !== checkCode) {
|
||||||
|
this.setState({ failureReason: LoginWithQRFailureReason.CheckCodeMismatch });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
|
||||||
|
// MSC4108-Flow: NewScanned
|
||||||
|
this.setState({ phase: Phase.Loading });
|
||||||
|
|
||||||
|
if (this.state.verificationUri) {
|
||||||
|
window.open(this.state.verificationUri, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ phase: Phase.WaitingForDevice });
|
||||||
|
|
||||||
|
// send secrets
|
||||||
|
await this.state.rendezvous.shareSecrets();
|
||||||
|
|
||||||
|
// done
|
||||||
|
this.onFinished(true);
|
||||||
|
} else {
|
||||||
|
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
|
||||||
|
throw new Error("New device flows around OIDC are not yet implemented");
|
||||||
|
}
|
||||||
|
} catch (e: RendezvousError | unknown) {
|
||||||
|
logger.error("Error whilst approving sign in", e);
|
||||||
|
this.setState({
|
||||||
|
phase: Phase.Error,
|
||||||
|
failureReason: e instanceof RendezvousError ? e.code : ClientRendezvousFailureReason.Unknown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onFailure = (reason: RendezvousFailureReason): void => {
|
||||||
|
if (this.state.phase === Phase.Error) return; // Already in failed state
|
||||||
logger.info(`Rendezvous failed: ${reason}`);
|
logger.info(`Rendezvous failed: ${reason}`);
|
||||||
this.setState({ phase: Phase.Error, failureReason: reason });
|
this.setState({ phase: Phase.Error, failureReason: reason });
|
||||||
};
|
};
|
||||||
|
@ -199,44 +308,72 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||||
this.setState({
|
this.setState({
|
||||||
rendezvous: undefined,
|
rendezvous: undefined,
|
||||||
confirmationDigits: undefined,
|
confirmationDigits: undefined,
|
||||||
|
verificationUri: undefined,
|
||||||
failureReason: undefined,
|
failureReason: undefined,
|
||||||
|
userCode: undefined,
|
||||||
|
checkCode: undefined,
|
||||||
|
homeserverBaseUrl: undefined,
|
||||||
|
lastScannedCode: undefined,
|
||||||
|
mediaPermissionError: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private onClick = async (type: Click): Promise<void> => {
|
private onClick = async (type: Click, checkCode?: string): Promise<void> => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Click.Cancel:
|
case Click.Cancel:
|
||||||
await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled);
|
if (this.state.rendezvous instanceof MSC3906Rendezvous) {
|
||||||
|
await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled);
|
||||||
|
} else {
|
||||||
|
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
|
||||||
|
}
|
||||||
this.reset();
|
this.reset();
|
||||||
this.props.onFinished(false);
|
this.onFinished(false);
|
||||||
break;
|
break;
|
||||||
case Click.Approve:
|
case Click.Approve:
|
||||||
await this.approveLogin();
|
await (this.props.legacy ? this.legacyApproveLogin() : this.approveLogin(checkCode));
|
||||||
break;
|
break;
|
||||||
case Click.Decline:
|
case Click.Decline:
|
||||||
await this.state.rendezvous?.declineLoginOnExistingDevice();
|
await this.state.rendezvous?.declineLoginOnExistingDevice();
|
||||||
this.reset();
|
this.reset();
|
||||||
this.props.onFinished(false);
|
this.onFinished(false);
|
||||||
break;
|
|
||||||
case Click.TryAgain:
|
|
||||||
this.reset();
|
|
||||||
await this.updateMode(this.props.mode);
|
|
||||||
break;
|
break;
|
||||||
case Click.Back:
|
case Click.Back:
|
||||||
await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled);
|
if (this.state.rendezvous instanceof MSC3906Rendezvous) {
|
||||||
this.props.onFinished(false);
|
await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled);
|
||||||
|
} else {
|
||||||
|
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
|
||||||
|
}
|
||||||
|
this.onFinished(false);
|
||||||
|
break;
|
||||||
|
case Click.ShowQr:
|
||||||
|
await this.updateMode(Mode.Show);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
|
if (this.state.rendezvous instanceof MSC3906Rendezvous) {
|
||||||
|
return (
|
||||||
|
<LoginWithQRFlow
|
||||||
|
onClick={this.onClick}
|
||||||
|
phase={this.state.phase}
|
||||||
|
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
|
||||||
|
confirmationDigits={
|
||||||
|
this.state.phase === Phase.LegacyConnected ? this.state.confirmationDigits : undefined
|
||||||
|
}
|
||||||
|
failureReason={this.state.failureReason}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginWithQRFlow
|
<LoginWithQRFlow
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
phase={this.state.phase}
|
phase={this.state.phase}
|
||||||
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
|
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
|
||||||
confirmationDigits={this.state.phase === Phase.Connected ? this.state.confirmationDigits : undefined}
|
failureReason={this.state.failureReason}
|
||||||
failureReason={this.state.phase === Phase.Error ? this.state.failureReason : undefined}
|
userCode={this.state.userCode}
|
||||||
|
checkCode={this.state.checkCode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,12 +14,16 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ReactNode } from "react";
|
import React, { createRef, ReactNode } from "react";
|
||||||
import { LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
|
import {
|
||||||
|
ClientRendezvousFailureReason,
|
||||||
|
LegacyRendezvousFailureReason,
|
||||||
|
MSC4108FailureReason,
|
||||||
|
} from "matrix-js-sdk/src/rendezvous";
|
||||||
import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg";
|
import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg";
|
||||||
import { Icon as CheckCircleSolidIcon } from "@vector-im/compound-design-tokens/icons/check-circle-solid.svg";
|
import { Icon as CheckCircleSolidIcon } from "@vector-im/compound-design-tokens/icons/check-circle-solid.svg";
|
||||||
import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg";
|
import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg";
|
||||||
import { Heading, Text } from "@vector-im/compound-web";
|
import { Heading, MFAInput, Text } from "@vector-im/compound-web";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
|
@ -30,13 +34,24 @@ import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg";
|
||||||
import { Click, Phase } from "./LoginWithQR-types";
|
import { Click, Phase } from "./LoginWithQR-types";
|
||||||
import SdkConfig from "../../../SdkConfig";
|
import SdkConfig from "../../../SdkConfig";
|
||||||
import { FailureReason, LoginWithQRFailureReason } from "./LoginWithQR";
|
import { FailureReason, LoginWithQRFailureReason } from "./LoginWithQR";
|
||||||
|
import { XOR } from "../../../@types/common";
|
||||||
|
import { ErrorMessage } from "../../structures/ErrorMessage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated the MSC3906 implementation is deprecated in favour of MSC4108.
|
||||||
|
*/
|
||||||
|
interface MSC3906Props extends Pick<Props, "phase" | "onClick" | "failureReason"> {
|
||||||
|
code?: string;
|
||||||
|
confirmationDigits?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
phase: Phase;
|
phase: Phase;
|
||||||
code?: string;
|
code?: Uint8Array;
|
||||||
onClick(type: Click): Promise<void>;
|
onClick(type: Click, checkCodeEntered?: string): Promise<void>;
|
||||||
failureReason?: FailureReason;
|
failureReason?: FailureReason;
|
||||||
confirmationDigits?: string;
|
userCode?: string;
|
||||||
|
checkCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// n.b MSC3886/MSC3903/MSC3906 that this is based on are now closed.
|
// n.b MSC3886/MSC3903/MSC3906 that this is based on are now closed.
|
||||||
|
@ -46,17 +61,19 @@ interface Props {
|
||||||
/**
|
/**
|
||||||
* A component that implements the UI for sign in and E2EE set up with a QR code.
|
* A component that implements the UI for sign in and E2EE set up with a QR code.
|
||||||
*
|
*
|
||||||
* This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906
|
* This supports the unstable features of MSC3906 and MSC4108
|
||||||
*/
|
*/
|
||||||
export default class LoginWithQRFlow extends React.Component<Props> {
|
export default class LoginWithQRFlow extends React.Component<XOR<Props, MSC3906Props>> {
|
||||||
public constructor(props: Props) {
|
private checkCodeInput = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
|
public constructor(props: XOR<Props, MSC3906Props>) {
|
||||||
super(props);
|
super(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleClick = (type: Click): ((e: React.FormEvent) => Promise<void>) => {
|
private handleClick = (type: Click): ((e: React.FormEvent) => Promise<void>) => {
|
||||||
return async (e: React.FormEvent): Promise<void> => {
|
return async (e: React.FormEvent): Promise<void> => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await this.props.onClick(type);
|
await this.props.onClick(type, type === Click.Approve ? this.checkCodeInput.current?.value : undefined);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -90,24 +107,26 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||||
let message: ReactNode | undefined;
|
let message: ReactNode | undefined;
|
||||||
|
|
||||||
switch (this.props.failureReason) {
|
switch (this.props.failureReason) {
|
||||||
case LegacyRendezvousFailureReason.UnsupportedAlgorithm:
|
case MSC4108FailureReason.UnsupportedProtocol:
|
||||||
case LegacyRendezvousFailureReason.UnsupportedTransport:
|
case LegacyRendezvousFailureReason.UnsupportedProtocol:
|
||||||
case LegacyRendezvousFailureReason.HomeserverLacksSupport:
|
|
||||||
title = _t("auth|qr_code_login|error_unsupported_protocol_title");
|
title = _t("auth|qr_code_login|error_unsupported_protocol_title");
|
||||||
message = _t("auth|qr_code_login|error_unsupported_protocol");
|
message = _t("auth|qr_code_login|error_unsupported_protocol");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MSC4108FailureReason.UserCancelled:
|
||||||
case LegacyRendezvousFailureReason.UserCancelled:
|
case LegacyRendezvousFailureReason.UserCancelled:
|
||||||
title = _t("auth|qr_code_login|error_user_cancelled_title");
|
title = _t("auth|qr_code_login|error_user_cancelled_title");
|
||||||
message = _t("auth|qr_code_login|error_user_cancelled");
|
message = _t("auth|qr_code_login|error_user_cancelled");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MSC4108FailureReason.AuthorizationExpired:
|
||||||
|
case ClientRendezvousFailureReason.Expired:
|
||||||
case LegacyRendezvousFailureReason.Expired:
|
case LegacyRendezvousFailureReason.Expired:
|
||||||
title = _t("auth|qr_code_login|error_expired_title");
|
title = _t("auth|qr_code_login|error_expired_title");
|
||||||
message = _t("auth|qr_code_login|error_expired");
|
message = _t("auth|qr_code_login|error_expired");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case LegacyRendezvousFailureReason.InvalidCode:
|
case ClientRendezvousFailureReason.InsecureChannelDetected:
|
||||||
title = _t("auth|qr_code_login|error_insecure_channel_detected_title");
|
title = _t("auth|qr_code_login|error_insecure_channel_detected_title");
|
||||||
message = (
|
message = (
|
||||||
<>
|
<>
|
||||||
|
@ -125,13 +144,13 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case LegacyRendezvousFailureReason.OtherDeviceAlreadySignedIn:
|
case ClientRendezvousFailureReason.OtherDeviceAlreadySignedIn:
|
||||||
success = true;
|
success = true;
|
||||||
title = _t("auth|qr_code_login|error_other_device_already_signed_in_title");
|
title = _t("auth|qr_code_login|error_other_device_already_signed_in_title");
|
||||||
message = _t("auth|qr_code_login|error_other_device_already_signed_in");
|
message = _t("auth|qr_code_login|error_other_device_already_signed_in");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case LegacyRendezvousFailureReason.UserDeclined:
|
case ClientRendezvousFailureReason.UserDeclined:
|
||||||
title = _t("auth|qr_code_login|error_user_declined_title");
|
title = _t("auth|qr_code_login|error_user_declined_title");
|
||||||
message = _t("auth|qr_code_login|error_user_declined");
|
message = _t("auth|qr_code_login|error_user_declined");
|
||||||
break;
|
break;
|
||||||
|
@ -141,8 +160,16 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||||
message = _t("auth|qr_code_login|error_rate_limited");
|
message = _t("auth|qr_code_login|error_rate_limited");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case LegacyRendezvousFailureReason.OtherDeviceNotSignedIn:
|
case ClientRendezvousFailureReason.ETagMissing:
|
||||||
case LegacyRendezvousFailureReason.Unknown:
|
title = _t("error|something_went_wrong");
|
||||||
|
message = _t("auth|qr_code_login|error_etag_missing");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MSC4108FailureReason.DeviceAlreadyExists:
|
||||||
|
case MSC4108FailureReason.DeviceNotFound:
|
||||||
|
case MSC4108FailureReason.UnexpectedMessageReceived:
|
||||||
|
case ClientRendezvousFailureReason.OtherDeviceNotSignedIn:
|
||||||
|
case ClientRendezvousFailureReason.Unknown:
|
||||||
default:
|
default:
|
||||||
title = _t("error|something_went_wrong");
|
title = _t("error|something_went_wrong");
|
||||||
message = _t("auth|qr_code_login|error_unexpected");
|
message = _t("auth|qr_code_login|error_unexpected");
|
||||||
|
@ -150,18 +177,6 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
className = "mx_LoginWithQR_error";
|
className = "mx_LoginWithQR_error";
|
||||||
backButton = false;
|
backButton = false;
|
||||||
buttons = (
|
|
||||||
<>
|
|
||||||
<AccessibleButton
|
|
||||||
data-testid="try-again-button"
|
|
||||||
kind="primary"
|
|
||||||
onClick={this.handleClick(Click.TryAgain)}
|
|
||||||
>
|
|
||||||
{_t("action|try_again")}
|
|
||||||
</AccessibleButton>
|
|
||||||
{this.cancelButton()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
main = (
|
main = (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
@ -179,7 +194,7 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Phase.Connected:
|
case Phase.LegacyConnected:
|
||||||
backButton = false;
|
backButton = false;
|
||||||
main = (
|
main = (
|
||||||
<>
|
<>
|
||||||
|
@ -213,9 +228,62 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case Phase.OutOfBandConfirmation:
|
||||||
|
backButton = false;
|
||||||
|
main = (
|
||||||
|
<>
|
||||||
|
<Heading as="h1" size="sm" weight="semibold">
|
||||||
|
{_t("auth|qr_code_login|check_code_heading")}
|
||||||
|
</Heading>
|
||||||
|
<Text size="md">{_t("auth|qr_code_login|check_code_explainer")}</Text>
|
||||||
|
<label htmlFor="mx_LoginWithQR_checkCode">
|
||||||
|
{_t("auth|qr_code_login|check_code_input_label")}
|
||||||
|
</label>
|
||||||
|
<MFAInput
|
||||||
|
className="mx_LoginWithQR_checkCode_input mx_no_textinput"
|
||||||
|
ref={this.checkCodeInput}
|
||||||
|
length={2}
|
||||||
|
autoFocus
|
||||||
|
id="mx_LoginWithQR_checkCode"
|
||||||
|
data-invalid={
|
||||||
|
this.props.failureReason === LoginWithQRFailureReason.CheckCodeMismatch
|
||||||
|
? true
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ErrorMessage
|
||||||
|
message={
|
||||||
|
this.props.failureReason === LoginWithQRFailureReason.CheckCodeMismatch
|
||||||
|
? _t("auth|qr_code_login|check_code_mismatch")
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
buttons = (
|
||||||
|
<>
|
||||||
|
<AccessibleButton
|
||||||
|
data-testid="approve-login-button"
|
||||||
|
kind="primary"
|
||||||
|
onClick={this.handleClick(Click.Approve)}
|
||||||
|
>
|
||||||
|
{_t("action|continue")}
|
||||||
|
</AccessibleButton>
|
||||||
|
<AccessibleButton
|
||||||
|
data-testid="decline-login-button"
|
||||||
|
kind="primary_outline"
|
||||||
|
onClick={this.handleClick(Click.Decline)}
|
||||||
|
>
|
||||||
|
{_t("action|cancel")}
|
||||||
|
</AccessibleButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
break;
|
||||||
case Phase.ShowingQR:
|
case Phase.ShowingQR:
|
||||||
if (this.props.code) {
|
if (this.props.code) {
|
||||||
const data = Buffer.from(this.props.code ?? "");
|
const data =
|
||||||
|
typeof this.props.code !== "string" ? this.props.code : Buffer.from(this.props.code ?? "");
|
||||||
|
|
||||||
main = (
|
main = (
|
||||||
<>
|
<>
|
||||||
|
@ -249,12 +317,19 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||||
case Phase.Loading:
|
case Phase.Loading:
|
||||||
main = this.simpleSpinner();
|
main = this.simpleSpinner();
|
||||||
break;
|
break;
|
||||||
case Phase.Connecting:
|
|
||||||
main = this.simpleSpinner(_t("auth|qr_code_login|connecting"));
|
|
||||||
buttons = this.cancelButton();
|
|
||||||
break;
|
|
||||||
case Phase.WaitingForDevice:
|
case Phase.WaitingForDevice:
|
||||||
main = this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"));
|
main = (
|
||||||
|
<>
|
||||||
|
{this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))}
|
||||||
|
{this.props.userCode ? (
|
||||||
|
<div>
|
||||||
|
<p>{_t("auth|qr_code_login|security_code")}</p>
|
||||||
|
<p>{_t("auth|qr_code_login|security_code_prompt")}</p>
|
||||||
|
<p>{this.props.userCode}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
buttons = this.cancelButton();
|
buttons = this.cancelButton();
|
||||||
break;
|
break;
|
||||||
case Phase.Verifying:
|
case Phase.Verifying:
|
||||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import TabbedView, { Tab, useActiveTabWithDefault } from "../../structures/TabbedView";
|
import TabbedView, { Tab, useActiveTabWithDefault } from "../../structures/TabbedView";
|
||||||
import { _t, _td } from "../../../languageHandler";
|
import { _t, _td } from "../../../languageHandler";
|
||||||
|
@ -41,6 +41,7 @@ import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
initialTabId?: UserTab;
|
initialTabId?: UserTab;
|
||||||
|
showMsc4108QrCode?: boolean;
|
||||||
sdkContext: SdkContextClass;
|
sdkContext: SdkContextClass;
|
||||||
onFinished(): void;
|
onFinished(): void;
|
||||||
}
|
}
|
||||||
|
@ -80,6 +81,8 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
|
||||||
export default function UserSettingsDialog(props: IProps): JSX.Element {
|
export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||||
const voipEnabled = useSettingValue<boolean>(UIFeature.Voip);
|
const voipEnabled = useSettingValue<boolean>(UIFeature.Voip);
|
||||||
const mjolnirEnabled = useSettingValue<boolean>("feature_mjolnir");
|
const mjolnirEnabled = useSettingValue<boolean>("feature_mjolnir");
|
||||||
|
// store this prop in state as changing tabs back and forth should clear it
|
||||||
|
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
|
||||||
|
|
||||||
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
|
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
|
||||||
const tabs: Tab<UserTab>[] = [];
|
const tabs: Tab<UserTab>[] = [];
|
||||||
|
@ -98,7 +101,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||||
UserTab.SessionManager,
|
UserTab.SessionManager,
|
||||||
_td("settings|sessions|title"),
|
_td("settings|sessions|title"),
|
||||||
"mx_UserSettingsDialog_sessionsIcon",
|
"mx_UserSettingsDialog_sessionsIcon",
|
||||||
<SessionManagerTab />,
|
<SessionManagerTab showMsc4108QrCode={showMsc4108QrCode} />,
|
||||||
undefined,
|
undefined,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -205,7 +208,12 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||||
return tabs as NonEmptyArray<Tab<UserTab>>;
|
return tabs as NonEmptyArray<Tab<UserTab>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [activeTabId, setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.General, props.initialTabId);
|
const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.General, props.initialTabId);
|
||||||
|
const setActiveTabId = (tabId: UserTab): void => {
|
||||||
|
_setActiveTabId(tabId);
|
||||||
|
// Clear this so switching away from the tab and back to it will not show the QR code again
|
||||||
|
setShowMsc4108QrCode(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// XXX: SDKContext is provided within the LoggedInView subtree.
|
// XXX: SDKContext is provided within the LoggedInView subtree.
|
||||||
|
|
|
@ -21,18 +21,26 @@ import {
|
||||||
GET_LOGIN_TOKEN_CAPABILITY,
|
GET_LOGIN_TOKEN_CAPABILITY,
|
||||||
Capabilities,
|
Capabilities,
|
||||||
IClientWellKnown,
|
IClientWellKnown,
|
||||||
|
OidcClientConfig,
|
||||||
|
MatrixClient,
|
||||||
|
DEVICE_CODE_SCOPE,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { Icon as QrCodeIcon } from "@vector-im/compound-design-tokens/icons/qr-code.svg";
|
import { Icon as QrCodeIcon } from "@vector-im/compound-design-tokens/icons/qr-code.svg";
|
||||||
|
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import AccessibleButton from "../../elements/AccessibleButton";
|
import AccessibleButton from "../../elements/AccessibleButton";
|
||||||
import SettingsSubsection from "../shared/SettingsSubsection";
|
import SettingsSubsection from "../shared/SettingsSubsection";
|
||||||
|
import SettingsStore from "../../../../settings/SettingsStore";
|
||||||
|
import { Features } from "../../../../settings/Settings";
|
||||||
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
onShowQr: () => void;
|
onShowQr: () => void;
|
||||||
versions?: IServerVersions;
|
versions?: IServerVersions;
|
||||||
capabilities?: Capabilities;
|
capabilities?: Capabilities;
|
||||||
wellKnown?: IClientWellKnown;
|
wellKnown?: IClientWellKnown;
|
||||||
|
oidcClientConfig?: OidcClientConfig;
|
||||||
|
isCrossSigningReady?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowQrLegacy(
|
function shouldShowQrLegacy(
|
||||||
|
@ -50,8 +58,40 @@ function shouldShowQrLegacy(
|
||||||
return getLoginTokenSupported && msc3886Supported;
|
return getLoginTokenSupported && msc3886Supported;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginWithQRSection: React.FC<IProps> = ({ onShowQr, versions, capabilities, wellKnown }) => {
|
export function shouldShowQr(
|
||||||
const offerShowQr = shouldShowQrLegacy(versions, wellKnown, capabilities);
|
cli: MatrixClient,
|
||||||
|
isCrossSigningReady: boolean,
|
||||||
|
oidcClientConfig?: OidcClientConfig,
|
||||||
|
versions?: IServerVersions,
|
||||||
|
wellKnown?: IClientWellKnown,
|
||||||
|
): boolean {
|
||||||
|
const msc4108Supported =
|
||||||
|
!!versions?.unstable_features?.["org.matrix.msc4108"] || !!wellKnown?.["io.element.rendezvous"]?.server;
|
||||||
|
|
||||||
|
const deviceAuthorizationGrantSupported =
|
||||||
|
oidcClientConfig?.metadata?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
deviceAuthorizationGrantSupported &&
|
||||||
|
msc4108Supported &&
|
||||||
|
SettingsStore.getValue(Features.OidcNativeFlow) &&
|
||||||
|
!!cli.getCrypto()?.exportSecretsBundle &&
|
||||||
|
isCrossSigningReady
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginWithQRSection: React.FC<IProps> = ({
|
||||||
|
onShowQr,
|
||||||
|
versions,
|
||||||
|
capabilities,
|
||||||
|
wellKnown,
|
||||||
|
oidcClientConfig,
|
||||||
|
isCrossSigningReady,
|
||||||
|
}) => {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const offerShowQr = oidcClientConfig
|
||||||
|
? shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions, wellKnown)
|
||||||
|
: shouldShowQrLegacy(versions, wellKnown, capabilities);
|
||||||
|
|
||||||
// don't show anything if no method is available
|
// don't show anything if no method is available
|
||||||
if (!offerShowQr) {
|
if (!offerShowQr) {
|
||||||
|
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { discoverAndValidateOIDCIssuerWellKnown, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { _t } from "../../../../../languageHandler";
|
import { _t } from "../../../../../languageHandler";
|
||||||
|
@ -32,7 +32,6 @@ import { 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 LoginWithQRSection from "../../devices/LoginWithQRSection";
|
||||||
import LoginWithQR from "../../../auth/LoginWithQR";
|
|
||||||
import { Mode } from "../../../auth/LoginWithQR-types";
|
import { Mode } from "../../../auth/LoginWithQR-types";
|
||||||
import { useAsyncMemo } from "../../../../../hooks/useAsyncMemo";
|
import { useAsyncMemo } from "../../../../../hooks/useAsyncMemo";
|
||||||
import QuestionDialog from "../../../dialogs/QuestionDialog";
|
import QuestionDialog from "../../../dialogs/QuestionDialog";
|
||||||
|
@ -41,6 +40,10 @@ import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionH
|
||||||
import { SettingsSection } from "../../shared/SettingsSection";
|
import { SettingsSection } from "../../shared/SettingsSection";
|
||||||
import { OidcLogoutDialog } from "../../../dialogs/oidc/OidcLogoutDialog";
|
import { OidcLogoutDialog } from "../../../dialogs/oidc/OidcLogoutDialog";
|
||||||
import { SDKContext } from "../../../../../contexts/SDKContext";
|
import { SDKContext } from "../../../../../contexts/SDKContext";
|
||||||
|
import Spinner from "../../../elements/Spinner";
|
||||||
|
|
||||||
|
// We import `LoginWithQR` asynchronously to avoid importing the entire Rust Crypto WASM into the main bundle.
|
||||||
|
const LoginWithQR = lazy(() => import("../../../auth/LoginWithQR"));
|
||||||
|
|
||||||
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
|
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
|
||||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||||
|
@ -148,7 +151,9 @@ const useSignOut = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const SessionManagerTab: React.FC = () => {
|
const SessionManagerTab: React.FC<{
|
||||||
|
showMsc4108QrCode?: boolean;
|
||||||
|
}> = ({ showMsc4108QrCode }) => {
|
||||||
const {
|
const {
|
||||||
devices,
|
devices,
|
||||||
dehydratedDeviceId,
|
dehydratedDeviceId,
|
||||||
|
@ -186,6 +191,20 @@ const SessionManagerTab: React.FC = () => {
|
||||||
const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]);
|
const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]);
|
||||||
const capabilities = useAsyncMemo(async () => matrixClient?.getCapabilities(), [matrixClient]);
|
const capabilities = useAsyncMemo(async () => matrixClient?.getCapabilities(), [matrixClient]);
|
||||||
const wellKnown = useMemo(() => matrixClient?.getClientWellKnown(), [matrixClient]);
|
const wellKnown = useMemo(() => matrixClient?.getClientWellKnown(), [matrixClient]);
|
||||||
|
const oidcClientConfig = useAsyncMemo(async () => {
|
||||||
|
try {
|
||||||
|
const authIssuer = await matrixClient?.getAuthIssuer();
|
||||||
|
if (authIssuer) {
|
||||||
|
return discoverAndValidateOIDCIssuerWellKnown(authIssuer.issuer);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to discover OIDC metadata", e);
|
||||||
|
}
|
||||||
|
}, [matrixClient]);
|
||||||
|
const isCrossSigningReady = useAsyncMemo(
|
||||||
|
async () => matrixClient.getCrypto()?.isCrossSigningReady() ?? false,
|
||||||
|
[matrixClient],
|
||||||
|
);
|
||||||
|
|
||||||
const onDeviceExpandToggle = (deviceId: ExtendedDevice["device_id"]): void => {
|
const onDeviceExpandToggle = (deviceId: ExtendedDevice["device_id"]): void => {
|
||||||
if (expandedDeviceIds.includes(deviceId)) {
|
if (expandedDeviceIds.includes(deviceId)) {
|
||||||
|
@ -268,7 +287,7 @@ const SessionManagerTab: React.FC = () => {
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [signInWithQrMode, setSignInWithQrMode] = useState<Mode | null>();
|
const [signInWithQrMode, setSignInWithQrMode] = useState<Mode | null>(showMsc4108QrCode ? Mode.Show : null);
|
||||||
|
|
||||||
const onQrFinish = useCallback(() => {
|
const onQrFinish = useCallback(() => {
|
||||||
setSignInWithQrMode(null);
|
setSignInWithQrMode(null);
|
||||||
|
@ -279,7 +298,16 @@ const SessionManagerTab: React.FC = () => {
|
||||||
}, [setSignInWithQrMode]);
|
}, [setSignInWithQrMode]);
|
||||||
|
|
||||||
if (signInWithQrMode) {
|
if (signInWithQrMode) {
|
||||||
return <LoginWithQR mode={signInWithQrMode} onFinished={onQrFinish} client={matrixClient} />;
|
return (
|
||||||
|
<Suspense fallback={<Spinner />}>
|
||||||
|
<LoginWithQR
|
||||||
|
mode={signInWithQrMode}
|
||||||
|
onFinished={onQrFinish}
|
||||||
|
client={matrixClient}
|
||||||
|
legacy={!oidcClientConfig && !showMsc4108QrCode}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -290,6 +318,8 @@ const SessionManagerTab: React.FC = () => {
|
||||||
versions={clientVersions}
|
versions={clientVersions}
|
||||||
capabilities={capabilities}
|
capabilities={capabilities}
|
||||||
wellKnown={wellKnown}
|
wellKnown={wellKnown}
|
||||||
|
oidcClientConfig={oidcClientConfig}
|
||||||
|
isCrossSigningReady={isCrossSigningReady}
|
||||||
/>
|
/>
|
||||||
<SecurityRecommendations
|
<SecurityRecommendations
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
|
|
@ -24,4 +24,9 @@ export interface OpenToTabPayload extends ActionPayload {
|
||||||
* The tab ID to open in the settings view to start, if possible.
|
* The tab ID to open in the settings view to start, if possible.
|
||||||
*/
|
*/
|
||||||
initialTabId?: string;
|
initialTabId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional properties to pass to the settings view.
|
||||||
|
*/
|
||||||
|
props?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -246,9 +246,13 @@
|
||||||
"phone_optional_label": "Phone (optional)",
|
"phone_optional_label": "Phone (optional)",
|
||||||
"qr_code_login": {
|
"qr_code_login": {
|
||||||
"approve_access_warning": "By approving access for this device, it will have full access to your account.",
|
"approve_access_warning": "By approving access for this device, it will have full access to your account.",
|
||||||
|
"check_code_explainer": "This will verify that the connection to your other device is secure.",
|
||||||
|
"check_code_heading": "Enter the number shown on your other device",
|
||||||
|
"check_code_input_label": "2-digit code",
|
||||||
|
"check_code_mismatch": "The numbers don't match",
|
||||||
"completing_setup": "Completing set up of your new device",
|
"completing_setup": "Completing set up of your new device",
|
||||||
"confirm_code_match": "Check that the code below matches with your other device:",
|
"confirm_code_match": "Check that the code below matches with your other device:",
|
||||||
"connecting": "Connecting…",
|
"error_etag_missing": "An unexpected error occurred. This may be due to a browser extension, proxy server, or server misconfiguration.",
|
||||||
"error_expired": "Sign in expired. Please try again.",
|
"error_expired": "Sign in expired. Please try again.",
|
||||||
"error_expired_title": "The sign in was not completed in time",
|
"error_expired_title": "The sign in was not completed in time",
|
||||||
"error_insecure_channel_detected": "A secure connection could not be made to the new device. Your existing devices are still safe and you don't need to worry about them.",
|
"error_insecure_channel_detected": "A secure connection could not be made to the new device. Your existing devices are still safe and you don't need to worry about them.",
|
||||||
|
@ -265,13 +269,15 @@
|
||||||
"error_unsupported_protocol_title": "Other device not compatible",
|
"error_unsupported_protocol_title": "Other device not compatible",
|
||||||
"error_user_cancelled": "The sign in was cancelled on the other device.",
|
"error_user_cancelled": "The sign in was cancelled on the other device.",
|
||||||
"error_user_cancelled_title": "Sign in request cancelled",
|
"error_user_cancelled_title": "Sign in request cancelled",
|
||||||
"error_user_declined": "You declined the request from your other device to sign in.",
|
"error_user_declined": "You or the account provider declined the sign in request.",
|
||||||
"error_user_declined_title": "Sign in declined",
|
"error_user_declined_title": "Sign in declined",
|
||||||
"follow_remaining_instructions": "Follow the instructions to link your other device",
|
"follow_remaining_instructions": "Follow the remaining instructions",
|
||||||
"open_element_other_device": "Open %(brand)s on your other device",
|
"open_element_other_device": "Open %(brand)s on your other device",
|
||||||
"point_the_camera": "Point the camera at the QR code shown here",
|
"point_the_camera": "Scan the QR code shown here",
|
||||||
"scan_code_instruction": "Scan the QR code with another device",
|
"scan_code_instruction": "Scan the QR code with another device",
|
||||||
"scan_qr_code": "Sign in with QR code",
|
"scan_qr_code": "Sign in with QR code",
|
||||||
|
"security_code": "Security code",
|
||||||
|
"security_code_prompt": "If asked, enter the code below on your other device.",
|
||||||
"select_qr_code": "Select \"%(scanQRCode)s\"",
|
"select_qr_code": "Select \"%(scanQRCode)s\"",
|
||||||
"sign_in_new_device": "Sign in new device",
|
"sign_in_new_device": "Sign in new device",
|
||||||
"waiting_for_device": "Waiting for device to sign in"
|
"waiting_for_device": "Waiting for device to sign in"
|
||||||
|
@ -3788,6 +3794,9 @@
|
||||||
"verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices."
|
"verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices."
|
||||||
},
|
},
|
||||||
"user_menu": {
|
"user_menu": {
|
||||||
|
"link_new_device": "Link new device",
|
||||||
|
"link_new_device_not_supported": "Not supported",
|
||||||
|
"link_new_device_not_supported_caption": "You need to sign in manually",
|
||||||
"settings": "All settings",
|
"settings": "All settings",
|
||||||
"switch_theme_dark": "Switch to dark mode",
|
"switch_theme_dark": "Switch to dark mode",
|
||||||
"switch_theme_light": "Switch to light mode"
|
"switch_theme_light": "Switch to light mode"
|
||||||
|
|
|
@ -16,8 +16,10 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { act, render, RenderResult, screen, waitFor } from "@testing-library/react";
|
import { act, render, RenderResult, screen, waitFor } from "@testing-library/react";
|
||||||
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
import { DEVICE_CODE_SCOPE, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
import UnwrappedUserMenu from "../../../src/components/structures/UserMenu";
|
import UnwrappedUserMenu from "../../../src/components/structures/UserMenu";
|
||||||
import { stubClient, wrapInSdkContext } from "../../test-utils";
|
import { stubClient, wrapInSdkContext } from "../../test-utils";
|
||||||
|
@ -31,6 +33,12 @@ import { TestSdkContext } from "../../TestSdkContext";
|
||||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||||
import LogoutDialog from "../../../src/components/views/dialogs/LogoutDialog";
|
import LogoutDialog from "../../../src/components/views/dialogs/LogoutDialog";
|
||||||
import Modal from "../../../src/Modal";
|
import Modal from "../../../src/Modal";
|
||||||
|
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||||
|
import { Features } from "../../../src/settings/Settings";
|
||||||
|
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||||
|
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
|
||||||
|
import { Action } from "../../../src/dispatcher/actions";
|
||||||
|
import { UserTab } from "../../../src/components/views/dialogs/UserTab";
|
||||||
|
|
||||||
describe("<UserMenu>", () => {
|
describe("<UserMenu>", () => {
|
||||||
let client: MatrixClient;
|
let client: MatrixClient;
|
||||||
|
@ -177,4 +185,48 @@ describe("<UserMenu>", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should render 'Link new device' button in OIDC native mode", async () => {
|
||||||
|
sdkContext.client = stubClient();
|
||||||
|
mocked(sdkContext.client.getAuthIssuer).mockResolvedValue({ issuer: "https://issuer/" });
|
||||||
|
const openIdMetadata = mockOpenIdConfiguration("https://issuer/");
|
||||||
|
openIdMetadata.grant_types_supported.push(DEVICE_CODE_SCOPE);
|
||||||
|
fetchMock.get("https://issuer/.well-known/openid-configuration", openIdMetadata);
|
||||||
|
fetchMock.get("https://issuer/jwks", {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
keys: [],
|
||||||
|
});
|
||||||
|
mocked(sdkContext.client.getVersions).mockResolvedValue({
|
||||||
|
versions: [],
|
||||||
|
unstable_features: {
|
||||||
|
"org.matrix.msc4108": true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mocked(sdkContext.client.waitForClientWellKnown).mockResolvedValue({});
|
||||||
|
mocked(sdkContext.client.getCrypto).mockReturnValue({
|
||||||
|
isCrossSigningReady: jest.fn().mockResolvedValue(true),
|
||||||
|
exportSecretsBundle: jest.fn().mockResolvedValue({}),
|
||||||
|
} as unknown as CryptoApi);
|
||||||
|
await SettingsStore.setValue(Features.OidcNativeFlow, null, SettingLevel.DEVICE, true);
|
||||||
|
const spy = jest.spyOn(defaultDispatcher, "dispatch");
|
||||||
|
|
||||||
|
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
|
||||||
|
render(<UserMenu isPanelCollapsed={true} />);
|
||||||
|
|
||||||
|
screen.getByRole("button", { name: /User menu/i }).click();
|
||||||
|
await expect(screen.findByText("Link new device")).resolves.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Assert the QR code is shown directly
|
||||||
|
screen.getByRole("menuitem", { name: "Link new device" }).click();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(spy).toHaveBeenCalledWith({
|
||||||
|
action: Action.ViewUserSettings,
|
||||||
|
initialTabId: UserTab.SessionManager,
|
||||||
|
props: { showMsc4108QrCode: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,13 @@ limitations under the License.
|
||||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||||
import { MockedObject, mocked } from "jest-mock";
|
import { MockedObject, mocked } from "jest-mock";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { MSC3906Rendezvous, LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
|
import {
|
||||||
|
MSC3906Rendezvous,
|
||||||
|
LegacyRendezvousFailureReason,
|
||||||
|
ClientRendezvousFailureReason,
|
||||||
|
MSC4108SignInWithQR,
|
||||||
|
MSC4108FailureReason,
|
||||||
|
} from "matrix-js-sdk/src/rendezvous";
|
||||||
import { HTTPError, LoginTokenPostResponse } from "matrix-js-sdk/src/matrix";
|
import { HTTPError, LoginTokenPostResponse } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import LoginWithQR from "../../../../../src/components/views/auth/LoginWithQR";
|
import LoginWithQR from "../../../../../src/components/views/auth/LoginWithQR";
|
||||||
|
@ -65,6 +71,7 @@ function unresolvedPromise<T>(): Promise<T> {
|
||||||
describe("<LoginWithQR />", () => {
|
describe("<LoginWithQR />", () => {
|
||||||
let client!: MockedObject<MatrixClient>;
|
let client!: MockedObject<MatrixClient>;
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
legacy: true,
|
||||||
mode: Mode.Show,
|
mode: Mode.Show,
|
||||||
onFinished: jest.fn(),
|
onFinished: jest.fn(),
|
||||||
};
|
};
|
||||||
|
@ -72,29 +79,10 @@ describe("<LoginWithQR />", () => {
|
||||||
const mockRendezvousCode = "mock-rendezvous-code";
|
const mockRendezvousCode = "mock-rendezvous-code";
|
||||||
const newDeviceId = "new-device-id";
|
const newDeviceId = "new-device-id";
|
||||||
|
|
||||||
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
|
|
||||||
<React.StrictMode>
|
|
||||||
<LoginWithQR {...defaultProps} {...props} />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockedFlow.mockReset();
|
mockedFlow.mockReset();
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
client = makeClient();
|
client = makeClient();
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockResolvedValue();
|
|
||||||
// @ts-ignore
|
|
||||||
// workaround for https://github.com/facebook/jest/issues/9675
|
|
||||||
MSC3906Rendezvous.prototype.code = mockRendezvousCode;
|
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "cancel").mockResolvedValue();
|
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockResolvedValue(mockConfirmationDigits);
|
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "declineLoginOnExistingDevice").mockResolvedValue();
|
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "approveLoginOnExistingDevice").mockResolvedValue(newDeviceId);
|
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockResolvedValue(undefined);
|
|
||||||
client.requestLoginToken.mockResolvedValue({
|
|
||||||
login_token: "token",
|
|
||||||
expires_in_ms: 1000 * 1000,
|
|
||||||
} as LoginTokenPostResponse); // we force the type here so that it works with versions of js-sdk that don't have r1 support yet
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -104,279 +92,374 @@ describe("<LoginWithQR />", () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no homeserver support", async () => {
|
describe("MSC3906", () => {
|
||||||
// simulate no support
|
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockRejectedValue("");
|
<React.StrictMode>
|
||||||
render(getComponent({ client }));
|
<LoginWithQR {...defaultProps} {...props} />
|
||||||
await waitFor(() =>
|
</React.StrictMode>
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.Error,
|
|
||||||
failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("failed to connect", async () => {
|
beforeEach(() => {
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockRejectedValue("");
|
jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockResolvedValue();
|
||||||
render(getComponent({ client }));
|
// @ts-ignore
|
||||||
await waitFor(() =>
|
// workaround for https://github.com/facebook/jest/issues/9675
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
MSC3906Rendezvous.prototype.code = mockRendezvousCode;
|
||||||
phase: Phase.Error,
|
jest.spyOn(MSC3906Rendezvous.prototype, "cancel").mockResolvedValue();
|
||||||
failureReason: LegacyRendezvousFailureReason.Unknown,
|
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockResolvedValue(mockConfirmationDigits);
|
||||||
onClick: expect.any(Function),
|
jest.spyOn(MSC3906Rendezvous.prototype, "declineLoginOnExistingDevice").mockResolvedValue();
|
||||||
}),
|
jest.spyOn(MSC3906Rendezvous.prototype, "approveLoginOnExistingDevice").mockResolvedValue(newDeviceId);
|
||||||
);
|
jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockResolvedValue(undefined);
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
client.requestLoginToken.mockResolvedValue({
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
login_token: "token",
|
||||||
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
expires_in_ms: 1000 * 1000,
|
||||||
});
|
} as LoginTokenPostResponse); // we force the type here so that it works with versions of js-sdk that don't have r1 support yet
|
||||||
|
|
||||||
test("render QR then cancel and try again", async () => {
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockImplementation(() => unresolvedPromise());
|
|
||||||
render(getComponent({ client, onFinished }));
|
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.ShowingQR,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// display QR code
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.ShowingQR,
|
|
||||||
code: mockRendezvousCode,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
});
|
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
|
||||||
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// cancel
|
|
||||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
|
||||||
await onClick(Click.Cancel);
|
|
||||||
expect(onFinished).toHaveBeenCalledWith(false);
|
|
||||||
expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled);
|
|
||||||
|
|
||||||
// try again
|
|
||||||
onClick(Click.TryAgain);
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.ShowingQR,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// display QR code
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.ShowingQR,
|
|
||||||
code: mockRendezvousCode,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("render QR then back", async () => {
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockReturnValue(unresolvedPromise());
|
|
||||||
render(getComponent({ client, onFinished }));
|
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.ShowingQR,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// display QR code
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.ShowingQR,
|
|
||||||
code: mockRendezvousCode,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
});
|
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
|
||||||
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// back
|
|
||||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
|
||||||
await onClick(Click.Back);
|
|
||||||
expect(onFinished).toHaveBeenCalledWith(false);
|
|
||||||
expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("render QR then decline", async () => {
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
render(getComponent({ client, onFinished }));
|
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
confirmationDigits: mockConfirmationDigits,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// decline
|
test("no homeserver support", async () => {
|
||||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
// simulate no support
|
||||||
await onClick(Click.Decline);
|
jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockRejectedValue("");
|
||||||
expect(onFinished).toHaveBeenCalledWith(false);
|
render(getComponent({ client }));
|
||||||
|
await waitFor(() =>
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
|
||||||
expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("approve - no crypto", async () => {
|
|
||||||
(client as any).crypto = undefined;
|
|
||||||
(client as any).getCrypto = () => undefined;
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
render(getComponent({ client, onFinished }));
|
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
confirmationDigits: mockConfirmationDigits,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
});
|
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
|
||||||
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// approve
|
|
||||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
|
||||||
await onClick(Click.Approve);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.WaitingForDevice,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
|
|
||||||
|
|
||||||
expect(onFinished).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("approve + verifying", async () => {
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockImplementation(() =>
|
|
||||||
unresolvedPromise(),
|
|
||||||
);
|
|
||||||
render(getComponent({ client, onFinished }));
|
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
confirmationDigits: mockConfirmationDigits,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
});
|
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
|
||||||
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// approve
|
|
||||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
|
||||||
onClick(Click.Approve);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.Verifying,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
|
|
||||||
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
|
|
||||||
// expect(onFinished).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("approve + verify", async () => {
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
render(getComponent({ client, onFinished }));
|
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
confirmationDigits: mockConfirmationDigits,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
});
|
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
|
||||||
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// approve
|
|
||||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
|
||||||
await onClick(Click.Approve);
|
|
||||||
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
|
|
||||||
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
|
|
||||||
expect(rendezvous.close).toHaveBeenCalled();
|
|
||||||
expect(onFinished).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("approve - rate limited", async () => {
|
|
||||||
mocked(client.requestLoginToken).mockRejectedValue(new HTTPError("rate limit reached", 429));
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
render(getComponent({ client, onFinished }));
|
|
||||||
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
|
||||||
phase: Phase.Connected,
|
|
||||||
confirmationDigits: mockConfirmationDigits,
|
|
||||||
onClick: expect.any(Function),
|
|
||||||
});
|
|
||||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
|
||||||
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// approve
|
|
||||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
|
||||||
await onClick(Click.Approve);
|
|
||||||
|
|
||||||
// the 429 error should be handled and mapped
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
phase: Phase.Error,
|
phase: Phase.Error,
|
||||||
failureReason: "rate_limited",
|
failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport,
|
||||||
|
onClick: expect.any(Function),
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
|
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed to connect", async () => {
|
||||||
|
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockRejectedValue("");
|
||||||
|
render(getComponent({ client }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.Error,
|
||||||
|
failureReason: ClientRendezvousFailureReason.Unknown,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("render QR then back", async () => {
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockReturnValue(unresolvedPromise());
|
||||||
|
render(getComponent({ client, onFinished }));
|
||||||
|
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.ShowingQR,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// display QR code
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.ShowingQR,
|
||||||
|
code: mockRendezvousCode,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// back
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Back);
|
||||||
|
expect(onFinished).toHaveBeenCalledWith(false);
|
||||||
|
expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("render QR then decline", async () => {
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
render(getComponent({ client, onFinished }));
|
||||||
|
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
confirmationDigits: mockConfirmationDigits,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
});
|
||||||
|
|
||||||
|
// decline
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Decline);
|
||||||
|
expect(onFinished).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("approve - no crypto", async () => {
|
||||||
|
(client as any).crypto = undefined;
|
||||||
|
(client as any).getCrypto = () => undefined;
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
render(getComponent({ client, onFinished }));
|
||||||
|
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
confirmationDigits: mockConfirmationDigits,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// approve
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Approve);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.WaitingForDevice,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
|
||||||
|
|
||||||
|
expect(onFinished).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("approve + verifying", async () => {
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockImplementation(() =>
|
||||||
|
unresolvedPromise(),
|
||||||
|
);
|
||||||
|
render(getComponent({ client, onFinished }));
|
||||||
|
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
confirmationDigits: mockConfirmationDigits,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// approve
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
onClick(Click.Approve);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.Verifying,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
|
||||||
|
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
|
||||||
|
// expect(onFinished).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("approve + verify", async () => {
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
render(getComponent({ client, onFinished }));
|
||||||
|
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
confirmationDigits: mockConfirmationDigits,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// approve
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Approve);
|
||||||
|
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
|
||||||
|
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.close).toHaveBeenCalled();
|
||||||
|
expect(onFinished).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("approve - rate limited", async () => {
|
||||||
|
mocked(client.requestLoginToken).mockRejectedValue(new HTTPError("rate limit reached", 429));
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
render(getComponent({ client, onFinished }));
|
||||||
|
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.LegacyConnected,
|
||||||
|
confirmationDigits: mockConfirmationDigits,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
});
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// approve
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Approve);
|
||||||
|
|
||||||
|
// the 429 error should be handled and mapped
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.Error,
|
||||||
|
failureReason: "rate_limited",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MSC4108", () => {
|
||||||
|
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
|
||||||
|
<React.StrictMode>
|
||||||
|
<LoginWithQR {...defaultProps} {...props} legacy={false} />
|
||||||
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test("render QR then back", async () => {
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
|
||||||
|
render(getComponent({ client, onFinished }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.ShowingQR,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
|
||||||
|
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||||
|
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// back
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Back);
|
||||||
|
expect(onFinished).toHaveBeenCalledWith(false);
|
||||||
|
expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed to connect", async () => {
|
||||||
|
render(getComponent({ client }));
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockRejectedValue(
|
||||||
|
new HTTPError("Internal Server Error", 500),
|
||||||
|
);
|
||||||
|
const fn = jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
|
||||||
|
await waitFor(() => expect(fn).toHaveBeenLastCalledWith(ClientRendezvousFailureReason.Unknown));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reciprocates login", async () => {
|
||||||
|
jest.spyOn(global.window, "open");
|
||||||
|
|
||||||
|
render(getComponent({ client }));
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
|
||||||
|
verificationUri: "mock-verification-uri",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.OutOfBandConfirmation,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Approve);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.WaitingForDevice,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles errors during reciprocation", async () => {
|
||||||
|
render(getComponent({ client }));
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.OutOfBandConfirmation,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockRejectedValue(
|
||||||
|
new HTTPError("Internal Server Error", 500),
|
||||||
|
);
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Approve);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phase: Phase.Error,
|
||||||
|
failureReason: ClientRendezvousFailureReason.Unknown,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles user cancelling during reciprocation", async () => {
|
||||||
|
render(getComponent({ client }));
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||||
|
phase: Phase.OutOfBandConfirmation,
|
||||||
|
onClick: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
|
||||||
|
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||||
|
await onClick(Click.Cancel);
|
||||||
|
|
||||||
|
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
|
||||||
|
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,7 +16,11 @@ limitations under the License.
|
||||||
|
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
|
import {
|
||||||
|
ClientRendezvousFailureReason,
|
||||||
|
LegacyRendezvousFailureReason,
|
||||||
|
MSC4108FailureReason,
|
||||||
|
} from "matrix-js-sdk/src/rendezvous";
|
||||||
|
|
||||||
import LoginWithQRFlow from "../../../../../src/components/views/auth/LoginWithQRFlow";
|
import LoginWithQRFlow from "../../../../../src/components/views/auth/LoginWithQRFlow";
|
||||||
import { LoginWithQRFailureReason, FailureReason } from "../../../../../src/components/views/auth/LoginWithQR";
|
import { LoginWithQRFailureReason, FailureReason } from "../../../../../src/components/views/auth/LoginWithQR";
|
||||||
|
@ -54,7 +58,7 @@ describe("<LoginWithQRFlow />", () => {
|
||||||
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
|
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
fireEvent.click(screen.getByTestId("cancel-button"));
|
fireEvent.click(screen.getByTestId("cancel-button"));
|
||||||
expect(onClick).toHaveBeenCalledWith(Click.Cancel);
|
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders QR code", async () => {
|
it("renders QR code", async () => {
|
||||||
|
@ -64,24 +68,16 @@ describe("<LoginWithQRFlow />", () => {
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders spinner while connecting", async () => {
|
|
||||||
const { container } = render(getComponent({ phase: Phase.Connecting }));
|
|
||||||
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
|
|
||||||
expect(container).toMatchSnapshot();
|
|
||||||
fireEvent.click(screen.getByTestId("cancel-button"));
|
|
||||||
expect(onClick).toHaveBeenCalledWith(Click.Cancel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders code when connected", async () => {
|
it("renders code when connected", async () => {
|
||||||
const { container } = render(getComponent({ phase: Phase.Connected, confirmationDigits: "mock-digits" }));
|
const { container } = render(getComponent({ phase: Phase.LegacyConnected, confirmationDigits: "mock-digits" }));
|
||||||
expect(screen.getAllByText("mock-digits")).toHaveLength(1);
|
expect(screen.getAllByText("mock-digits")).toHaveLength(1);
|
||||||
expect(screen.getAllByTestId("decline-login-button")).toHaveLength(1);
|
expect(screen.getAllByTestId("decline-login-button")).toHaveLength(1);
|
||||||
expect(screen.getAllByTestId("approve-login-button")).toHaveLength(1);
|
expect(screen.getAllByTestId("approve-login-button")).toHaveLength(1);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
fireEvent.click(screen.getByTestId("decline-login-button"));
|
fireEvent.click(screen.getByTestId("decline-login-button"));
|
||||||
expect(onClick).toHaveBeenCalledWith(Click.Decline);
|
expect(onClick).toHaveBeenCalledWith(Click.Decline, undefined);
|
||||||
fireEvent.click(screen.getByTestId("approve-login-button"));
|
fireEvent.click(screen.getByTestId("approve-login-button"));
|
||||||
expect(onClick).toHaveBeenCalledWith(Click.Approve);
|
expect(onClick).toHaveBeenCalledWith(Click.Approve, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders spinner while signing in", async () => {
|
it("renders spinner while signing in", async () => {
|
||||||
|
@ -89,7 +85,7 @@ describe("<LoginWithQRFlow />", () => {
|
||||||
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
|
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
fireEvent.click(screen.getByTestId("cancel-button"));
|
fireEvent.click(screen.getByTestId("cancel-button"));
|
||||||
expect(onClick).toHaveBeenCalledWith(Click.Cancel);
|
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders spinner while verifying", async () => {
|
it("renders spinner while verifying", async () => {
|
||||||
|
@ -97,10 +93,17 @@ describe("<LoginWithQRFlow />", () => {
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders check code confirmation", async () => {
|
||||||
|
const { container } = render(getComponent({ phase: Phase.OutOfBandConfirmation }));
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
describe("errors", () => {
|
describe("errors", () => {
|
||||||
for (const failureReason of [
|
for (const failureReason of [
|
||||||
...Object.values(LegacyRendezvousFailureReason),
|
...Object.values(LegacyRendezvousFailureReason),
|
||||||
|
...Object.values(MSC4108FailureReason),
|
||||||
...Object.values(LoginWithQRFailureReason),
|
...Object.values(LoginWithQRFailureReason),
|
||||||
|
...Object.values(ClientRendezvousFailureReason),
|
||||||
]) {
|
]) {
|
||||||
it(`renders ${failureReason}`, async () => {
|
it(`renders ${failureReason}`, async () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
@ -110,10 +113,7 @@ describe("<LoginWithQRFlow />", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
|
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
|
||||||
expect(screen.getAllByTestId("try-again-button")).toHaveLength(1);
|
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
fireEvent.click(screen.getByTestId("try-again-button"));
|
|
||||||
expect(onClick).toHaveBeenCalledWith(Click.TryAgain);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,11 +18,17 @@ import { render } from "@testing-library/react";
|
||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
import { IClientWellKnown, IServerVersions, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "matrix-js-sdk/src/matrix";
|
import { IClientWellKnown, IServerVersions, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "matrix-js-sdk/src/matrix";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
import LoginWithQRSection from "../../../../../src/components/views/settings/devices/LoginWithQRSection";
|
import LoginWithQRSection from "../../../../../src/components/views/settings/devices/LoginWithQRSection";
|
||||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||||
|
|
||||||
function makeClient(wellKnown: IClientWellKnown) {
|
function makeClient(wellKnown: IClientWellKnown) {
|
||||||
|
const crypto = mocked({
|
||||||
|
supportsSecretsForQrLogin: jest.fn().mockReturnValue(true),
|
||||||
|
isCrossSigningReady: jest.fn().mockReturnValue(true),
|
||||||
|
});
|
||||||
|
|
||||||
return mocked({
|
return mocked({
|
||||||
getUser: jest.fn(),
|
getUser: jest.fn(),
|
||||||
isGuest: jest.fn().mockReturnValue(false),
|
isGuest: jest.fn().mockReturnValue(false),
|
||||||
|
@ -38,6 +44,7 @@ function makeClient(wellKnown: IClientWellKnown) {
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
},
|
},
|
||||||
getClientWellKnown: jest.fn().mockReturnValue(wellKnown),
|
getClientWellKnown: jest.fn().mockReturnValue(wellKnown),
|
||||||
|
getCrypto: jest.fn().mockReturnValue(crypto),
|
||||||
} as unknown as MatrixClient);
|
} as unknown as MatrixClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,68 +60,105 @@ describe("<LoginWithQRSection />", () => {
|
||||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient({}));
|
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient({}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultProps = {
|
describe("MSC3906", () => {
|
||||||
onShowQr: () => {},
|
const defaultProps = {
|
||||||
versions: makeVersions({}),
|
onShowQr: () => {},
|
||||||
wellKnown: {},
|
versions: makeVersions({}),
|
||||||
};
|
wellKnown: {},
|
||||||
|
};
|
||||||
|
|
||||||
const getComponent = (props = {}) => <LoginWithQRSection {...defaultProps} {...props} />;
|
const getComponent = (props = {}) => <LoginWithQRSection {...defaultProps} {...props} />;
|
||||||
|
|
||||||
describe("should not render", () => {
|
describe("should not render", () => {
|
||||||
it("no support at all", () => {
|
it("no support at all", () => {
|
||||||
const { container } = render(getComponent());
|
const { container } = render(getComponent());
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only get_login_token enabled", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
getComponent({ capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } } }),
|
||||||
|
);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MSC3886 + get_login_token disabled", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
getComponent({
|
||||||
|
versions: makeVersions({ "org.matrix.msc3886": true }),
|
||||||
|
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: false } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only get_login_token enabled", async () => {
|
describe("should render panel", () => {
|
||||||
const { container } = render(
|
it("get_login_token + MSC3886", async () => {
|
||||||
getComponent({ capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } } }),
|
const { container } = render(
|
||||||
);
|
getComponent({
|
||||||
expect(container).toMatchSnapshot();
|
versions: makeVersions({
|
||||||
});
|
"org.matrix.msc3886": true,
|
||||||
|
}),
|
||||||
|
capabilities: {
|
||||||
|
[GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
it("MSC3886 + get_login_token disabled", async () => {
|
it("get_login_token + .well-known", async () => {
|
||||||
const { container } = render(
|
const wellKnown = {
|
||||||
getComponent({
|
"io.element.rendezvous": {
|
||||||
versions: makeVersions({ "org.matrix.msc3886": true }),
|
server: "https://rz.local",
|
||||||
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: false } },
|
},
|
||||||
}),
|
};
|
||||||
);
|
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient(wellKnown));
|
||||||
expect(container).toMatchSnapshot();
|
const { container } = render(
|
||||||
|
getComponent({
|
||||||
|
versions: makeVersions({}),
|
||||||
|
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } },
|
||||||
|
wellKnown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should render panel", () => {
|
describe("MSC4108", () => {
|
||||||
it("get_login_token + MSC3886", async () => {
|
describe("MSC4108", () => {
|
||||||
const { container } = render(
|
const defaultProps = {
|
||||||
getComponent({
|
onShowQr: () => {},
|
||||||
versions: makeVersions({
|
versions: makeVersions({ "org.matrix.msc4108": true }),
|
||||||
"org.matrix.msc3886": true,
|
wellKnown: {},
|
||||||
}),
|
|
||||||
capabilities: {
|
|
||||||
[GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(container).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("get_login_token + .well-known", async () => {
|
|
||||||
const wellKnown = {
|
|
||||||
"io.element.rendezvous": {
|
|
||||||
server: "https://rz.local",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient(wellKnown));
|
|
||||||
const { container } = render(
|
const getComponent = (props = {}) => <LoginWithQRSection {...defaultProps} {...props} />;
|
||||||
getComponent({
|
|
||||||
versions: makeVersions({}),
|
let client: MatrixClient;
|
||||||
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } },
|
beforeEach(() => {
|
||||||
wellKnown,
|
client = makeClient({});
|
||||||
}),
|
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
|
||||||
);
|
});
|
||||||
expect(container).toMatchSnapshot();
|
|
||||||
|
test("no homeserver support", async () => {
|
||||||
|
const { container } = render(getComponent({ versions: makeVersions({ "org.matrix.msc4108": false }) }));
|
||||||
|
expect(container.textContent).toBe(""); // show nothing
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no support in crypto", async () => {
|
||||||
|
client.getCrypto()!.exportSecretsBundle = undefined;
|
||||||
|
const { container } = render(getComponent({ client }));
|
||||||
|
expect(container.textContent).toBe(""); // show nothing
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed to connect", async () => {
|
||||||
|
fetchMock.catch(500);
|
||||||
|
const { container } = render(getComponent({ client }));
|
||||||
|
expect(container.textContent).toBe(""); // show nothing
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,40 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`<LoginWithQRFlow /> errors renders data_mismatch 1`] = `
|
exports[`<LoginWithQRFlow /> errors renders authorization_expired 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
|
>
|
||||||
|
The sign in was not completed in time
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
Sign in expired. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders check_code_mismatch 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR mx_LoginWithQR_error"
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
@ -29,24 +63,109 @@ exports[`<LoginWithQRFlow /> errors renders data_mismatch 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders device_already_exists 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Try again
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h1
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Something went wrong!
|
||||||
</div>
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
An unexpected error occurred. The request to connect your other device has been cancelled.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders device_not_found 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
|
>
|
||||||
|
Something went wrong!
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
An unexpected error occurred. The request to connect your other device has been cancelled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders etag_missing 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
|
>
|
||||||
|
Something went wrong!
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
An unexpected error occurred. This may be due to a browser extension, proxy server, or server misconfiguration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -80,24 +199,41 @@ exports[`<LoginWithQRFlow /> errors renders expired 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders expired 2`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Try again
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h1
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Cancel
|
The sign in was not completed in time
|
||||||
</div>
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
Sign in expired. Please try again.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -121,39 +257,56 @@ exports[`<LoginWithQRFlow /> errors renders homeserver_lacks_support 1`] = `
|
||||||
<h1
|
<h1
|
||||||
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
>
|
>
|
||||||
Other device not compatible
|
Something went wrong!
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
data-testid="cancellation-message"
|
data-testid="cancellation-message"
|
||||||
>
|
>
|
||||||
This device does not support signing in to the other device with a QR code.
|
An unexpected error occurred. The request to connect your other device has been cancelled.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<LoginWithQRFlow /> errors renders invalid_code 1`] = `
|
exports[`<LoginWithQRFlow /> errors renders homeserver_lacks_support 2`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
|
>
|
||||||
|
Something went wrong!
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
An unexpected error occurred. The request to connect your other device has been cancelled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders insecure_channel_detected 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR mx_LoginWithQR_error"
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
@ -195,24 +348,41 @@ exports[`<LoginWithQRFlow /> errors renders invalid_code 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders invalid_code 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Try again
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h1
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Something went wrong!
|
||||||
</div>
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
An unexpected error occurred. The request to connect your other device has been cancelled.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -246,24 +416,7 @@ exports[`<LoginWithQRFlow /> errors renders other_device_already_signed_in 1`] =
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -297,24 +450,7 @@ exports[`<LoginWithQRFlow /> errors renders other_device_not_signed_in 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -348,24 +484,41 @@ exports[`<LoginWithQRFlow /> errors renders rate_limited 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders unexpected_message_received 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Try again
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h1
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Something went wrong!
|
||||||
</div>
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
An unexpected error occurred. The request to connect your other device has been cancelled.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -399,24 +552,41 @@ exports[`<LoginWithQRFlow /> errors renders unknown 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders unknown 2`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Try again
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h1
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Something went wrong!
|
||||||
</div>
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
An unexpected error occurred. The request to connect your other device has been cancelled.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -440,39 +610,22 @@ exports[`<LoginWithQRFlow /> errors renders unsupported_algorithm 1`] = `
|
||||||
<h1
|
<h1
|
||||||
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
>
|
>
|
||||||
Other device not compatible
|
Something went wrong!
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
data-testid="cancellation-message"
|
data-testid="cancellation-message"
|
||||||
>
|
>
|
||||||
This device does not support signing in to the other device with a QR code.
|
An unexpected error occurred. The request to connect your other device has been cancelled.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<LoginWithQRFlow /> errors renders unsupported_transport 1`] = `
|
exports[`<LoginWithQRFlow /> errors renders unsupported_protocol 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR mx_LoginWithQR_error"
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
@ -501,24 +654,41 @@ exports[`<LoginWithQRFlow /> errors renders unsupported_transport 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders unsupported_protocol 2`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Try again
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h1
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Other device not compatible
|
||||||
</div>
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
This device does not support signing in to the other device with a QR code.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -552,24 +722,41 @@ exports[`<LoginWithQRFlow /> errors renders user_cancelled 1`] = `
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders user_cancelled 2`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Try again
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h1
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Sign in request cancelled
|
||||||
</div>
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
The sign in was cancelled on the other device.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -598,29 +785,46 @@ exports[`<LoginWithQRFlow /> errors renders user_declined 1`] = `
|
||||||
<p
|
<p
|
||||||
data-testid="cancellation-message"
|
data-testid="cancellation-message"
|
||||||
>
|
>
|
||||||
You declined the request from your other device to sign in.
|
You or the account provider declined the sign in request.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_LoginWithQR_buttons"
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> errors renders user_declined 2`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR mx_LoginWithQR_error"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
|
||||||
data-testid="try-again-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Try again
|
<div
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h1
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Sign in declined
|
||||||
</div>
|
</h1>
|
||||||
|
<p
|
||||||
|
data-testid="cancellation-message"
|
||||||
|
>
|
||||||
|
You or the account provider declined the sign in request.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -686,10 +890,10 @@ exports[`<LoginWithQRFlow /> renders QR code 1`] = `
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Point the camera at the QR code shown here
|
Scan the QR code shown here
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Follow the instructions to link your other device
|
Follow the remaining instructions
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
@ -700,6 +904,80 @@ exports[`<LoginWithQRFlow /> renders QR code 1`] = `
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`<LoginWithQRFlow /> renders check code confirmation 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR"
|
||||||
|
data-testid="login-with-qr"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_main"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
|
||||||
|
>
|
||||||
|
Enter the number shown on your other device
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
|
||||||
|
>
|
||||||
|
This will verify that the connection to your other device is secure.
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
for="mx_LoginWithQR_checkCode"
|
||||||
|
>
|
||||||
|
2-digit code
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="_container_9zyti_18 mx_LoginWithQR_checkCode_input mx_no_textinput"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
class="_control_9zyti_33"
|
||||||
|
id="mx_LoginWithQR_checkCode"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="2"
|
||||||
|
minlength="0"
|
||||||
|
pattern="\\d{2}"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_digit_9zyti_57"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_digit_9zyti_57"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_ErrorMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_LoginWithQR_buttons"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
||||||
|
data-testid="approve-login-button"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
||||||
|
data-testid="decline-login-button"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`<LoginWithQRFlow /> renders code when connected 1`] = `
|
exports[`<LoginWithQRFlow /> renders code when connected 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
@ -752,72 +1030,6 @@ exports[`<LoginWithQRFlow /> renders code when connected 1`] = `
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<LoginWithQRFlow /> renders spinner while connecting 1`] = `
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="mx_LoginWithQR"
|
|
||||||
data-testid="login-with-qr"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_LoginWithQR_heading"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-label="Back"
|
|
||||||
class="mx_AccessibleButton mx_LoginWithQR_BackButton"
|
|
||||||
data-testid="back-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_LoginWithQR_breadcrumbs"
|
|
||||||
>
|
|
||||||
Sessions
|
|
||||||
/
|
|
||||||
Link new device
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_LoginWithQR_main"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_LoginWithQR_spinner"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="mx_Spinner"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-label="Loading…"
|
|
||||||
class="mx_Spinner_icon"
|
|
||||||
data-testid="spinner"
|
|
||||||
role="progressbar"
|
|
||||||
style="width: 32px; height: 32px;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
Connecting…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_LoginWithQR_buttons"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
|
|
||||||
data-testid="cancel-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`<LoginWithQRFlow /> renders spinner while loading 1`] = `
|
exports[`<LoginWithQRFlow /> renders spinner while loading 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`<LoginWithQRSection /> should not render MSC3886 + get_login_token disabled 1`] = `<div />`;
|
exports[`<LoginWithQRSection /> MSC3906 should not render MSC3886 + get_login_token disabled 1`] = `<div />`;
|
||||||
|
|
||||||
exports[`<LoginWithQRSection /> should not render no support at all 1`] = `<div />`;
|
exports[`<LoginWithQRSection /> MSC3906 should not render no support at all 1`] = `<div />`;
|
||||||
|
|
||||||
exports[`<LoginWithQRSection /> should not render only get_login_token enabled 1`] = `<div />`;
|
exports[`<LoginWithQRSection /> MSC3906 should not render only get_login_token enabled 1`] = `<div />`;
|
||||||
|
|
||||||
exports[`<LoginWithQRSection /> should render panel get_login_token + .well-known 1`] = `
|
exports[`<LoginWithQRSection /> MSC3906 should render panel get_login_token + .well-known 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="mx_SettingsSubsection"
|
class="mx_SettingsSubsection"
|
||||||
|
@ -48,7 +48,7 @@ exports[`<LoginWithQRSection /> should render panel get_login_token + .well-know
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<LoginWithQRSection /> should render panel get_login_token + MSC3886 1`] = `
|
exports[`<LoginWithQRSection /> MSC3906 should render panel get_login_token + MSC3886 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="mx_SettingsSubsection"
|
class="mx_SettingsSubsection"
|
||||||
|
|
|
@ -34,6 +34,7 @@ import {
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { mocked, MockedObject } from "jest-mock";
|
import { mocked, MockedObject } from "jest-mock";
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearAllModals,
|
clearAllModals,
|
||||||
|
@ -53,6 +54,8 @@ import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
||||||
import { getClientInformationEventType } from "../../../../../../src/utils/device/clientInformation";
|
import { getClientInformationEventType } from "../../../../../../src/utils/device/clientInformation";
|
||||||
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
|
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
|
||||||
import { OidcClientStore } from "../../../../../../src/stores/oidc/OidcClientStore";
|
import { OidcClientStore } from "../../../../../../src/stores/oidc/OidcClientStore";
|
||||||
|
import { mockOpenIdConfiguration } from "../../../../../test-utils/oidc";
|
||||||
|
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||||
|
|
||||||
mockPlatformPeg();
|
mockPlatformPeg();
|
||||||
|
|
||||||
|
@ -119,6 +122,8 @@ describe("<SessionManagerTab />", () => {
|
||||||
getDeviceVerificationStatus: jest.fn(),
|
getDeviceVerificationStatus: jest.fn(),
|
||||||
getUserDeviceInfo: jest.fn(),
|
getUserDeviceInfo: jest.fn(),
|
||||||
requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
|
requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
|
||||||
|
supportsSecretsForQrLogin: jest.fn().mockReturnValue(false),
|
||||||
|
isCrossSigningReady: jest.fn().mockReturnValue(true),
|
||||||
} as unknown as CryptoApi);
|
} as unknown as CryptoApi);
|
||||||
|
|
||||||
let mockClient!: MockedObject<MatrixClient>;
|
let mockClient!: MockedObject<MatrixClient>;
|
||||||
|
@ -127,7 +132,9 @@ describe("<SessionManagerTab />", () => {
|
||||||
const defaultProps = {};
|
const defaultProps = {};
|
||||||
const getComponent = (props = {}): React.ReactElement => (
|
const getComponent = (props = {}): React.ReactElement => (
|
||||||
<SDKContext.Provider value={sdkContext}>
|
<SDKContext.Provider value={sdkContext}>
|
||||||
<SessionManagerTab {...defaultProps} {...props} />
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<SessionManagerTab {...defaultProps} {...props} />
|
||||||
|
</MatrixClientContext.Provider>
|
||||||
</SDKContext.Provider>
|
</SDKContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -207,6 +214,7 @@ describe("<SessionManagerTab />", () => {
|
||||||
getPushers: jest.fn(),
|
getPushers: jest.fn(),
|
||||||
setPusher: jest.fn(),
|
setPusher: jest.fn(),
|
||||||
setLocalNotificationSettings: jest.fn(),
|
setLocalNotificationSettings: jest.fn(),
|
||||||
|
getAuthIssuer: jest.fn().mockReturnValue(new Promise(() => {})),
|
||||||
});
|
});
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.spyOn(logger, "error").mockRestore();
|
jest.spyOn(logger, "error").mockRestore();
|
||||||
|
@ -1664,7 +1672,7 @@ describe("<SessionManagerTab />", () => {
|
||||||
expect(checkbox.getAttribute("aria-checked")).toEqual("false");
|
expect(checkbox.getAttribute("aria-checked")).toEqual("false");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("QR code login", () => {
|
describe("MSC3906 QR code login", () => {
|
||||||
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
|
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -1694,13 +1702,71 @@ describe("<SessionManagerTab />", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enters qr code login section when show QR code button clicked", async () => {
|
it("enters qr code login section when show QR code button clicked", async () => {
|
||||||
const { getByText, getByTestId } = render(getComponent());
|
const { getByText, findByTestId } = render(getComponent());
|
||||||
// wait for versions call to settle
|
// wait for versions call to settle
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
fireEvent.click(getByText("Show QR code"));
|
fireEvent.click(getByText("Show QR code"));
|
||||||
|
|
||||||
expect(getByTestId("login-with-qr")).toBeTruthy();
|
await expect(findByTestId("login-with-qr")).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MSC4108 QR code login", () => {
|
||||||
|
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
|
||||||
|
const issuer = "https://issuer.org";
|
||||||
|
const openIdConfiguration = mockOpenIdConfiguration(issuer);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
settingsValueSpy.mockClear().mockReturnValue(true);
|
||||||
|
// enable server support for qr login
|
||||||
|
mockClient.getVersions.mockResolvedValue({
|
||||||
|
versions: [],
|
||||||
|
unstable_features: {
|
||||||
|
"org.matrix.msc4108": true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockClient.getCapabilities.mockResolvedValue({
|
||||||
|
[GET_LOGIN_TOKEN_CAPABILITY.name]: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockClient.getAuthIssuer.mockResolvedValue({ issuer });
|
||||||
|
mockCrypto.exportSecretsBundle = jest.fn();
|
||||||
|
fetchMock.mock(`${issuer}/.well-known/openid-configuration`, {
|
||||||
|
...openIdConfiguration,
|
||||||
|
grant_types_supported: [
|
||||||
|
...openIdConfiguration.grant_types_supported,
|
||||||
|
"urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fetchMock.mock(openIdConfiguration.jwks_uri!, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
keys: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders qr code login section", async () => {
|
||||||
|
const { getByText } = render(getComponent());
|
||||||
|
|
||||||
|
// wait for versions call to settle
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getByText("Link new device")).toBeTruthy();
|
||||||
|
expect(getByText("Show QR code")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters qr code login section when show QR code button clicked", async () => {
|
||||||
|
const { getByText, findByTestId } = render(getComponent());
|
||||||
|
// wait for versions call to settle
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
fireEvent.click(getByText("Show QR code"));
|
||||||
|
|
||||||
|
await expect(findByTestId("login-with-qr")).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import { MethodLikeKeys, mocked, MockedObject, PropertyLikeKeys } from "jest-mock";
|
import { MethodLikeKeys, mocked, MockedObject, PropertyLikeKeys } from "jest-mock";
|
||||||
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
|
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
|
||||||
import { MatrixClient, Room, User } from "matrix-js-sdk/src/matrix";
|
import { MatrixClient, Room, MatrixError, User } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||||
|
|
||||||
|
@ -136,6 +136,7 @@ export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixC
|
||||||
isVersionSupported: jest.fn().mockResolvedValue(false),
|
isVersionSupported: jest.fn().mockResolvedValue(false),
|
||||||
getVersions: jest.fn().mockResolvedValue({}),
|
getVersions: jest.fn().mockResolvedValue({}),
|
||||||
isFallbackICEServerAllowed: jest.fn(),
|
isFallbackICEServerAllowed: jest.fn(),
|
||||||
|
getAuthIssuer: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNKNOWN" }, 404)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mockClientMethodsDevice = (
|
export const mockClientMethodsDevice = (
|
||||||
|
@ -179,4 +180,5 @@ export const mockClientMethodsCrypto = (): Partial<
|
||||||
|
|
||||||
export const mockClientMethodsRooms = (rooms: Room[] = []): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
|
export const mockClientMethodsRooms = (rooms: Room[] = []): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
|
||||||
getRooms: jest.fn().mockReturnValue(rooms),
|
getRooms: jest.fn().mockReturnValue(rooms),
|
||||||
|
getRoom: jest.fn((roomId) => rooms.find((r) => r.roomId === roomId) ?? null),
|
||||||
});
|
});
|
||||||
|
|
|
@ -45,6 +45,7 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
|
||||||
token_endpoint: issuer + "token",
|
token_endpoint: issuer + "token",
|
||||||
authorization_endpoint: issuer + "auth",
|
authorization_endpoint: issuer + "auth",
|
||||||
registration_endpoint: issuer + "registration",
|
registration_endpoint: issuer + "registration",
|
||||||
|
device_authorization_endpoint: issuer + "device",
|
||||||
jwks_uri: issuer + "jwks",
|
jwks_uri: issuer + "jwks",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
|
|
|
@ -272,6 +272,7 @@ export function createTestClient(): MatrixClient {
|
||||||
baseUrl: "https://matrix-client.matrix.org",
|
baseUrl: "https://matrix-client.matrix.org",
|
||||||
matrixRTC: createStubMatrixRTC(),
|
matrixRTC: createStubMatrixRTC(),
|
||||||
isFallbackICEServerAllowed: jest.fn().mockReturnValue(false),
|
isFallbackICEServerAllowed: jest.fn().mockReturnValue(false),
|
||||||
|
getAuthIssuer: jest.fn(),
|
||||||
} as unknown as MatrixClient;
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
client.reEmitter = new ReEmitter(client);
|
client.reEmitter = new ReEmitter(client);
|
||||||
|
|
Loading…
Reference in a new issue