MSC4108 support OIDC QR code login (#12370)

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
Michael Telatynski 2024-06-06 09:57:28 +01:00 committed by GitHub
parent ca7760789b
commit 1677ed1be0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1558 additions and 733 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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")}

View file

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

View file

@ -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}
/> />
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 },
});
});
});
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
}); });
}); });
}); });

View file

@ -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),
}); });

View file

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

View file

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