[Labs] Sign in with QR on new EW using generated QR for MSC4108 v2024 (#33184)
* PoC Sign in with QR on new EW using generated QR for MSC4108 v2024 * Revert package.json changes * Prettier * Fix i18n * Tidy up * Remove unused state variable * Iterate tests * Partial revert * Iterate * Wire up qr_login route * Iterate UI * Fix React dev mode double rendering issue * Fix react key warning * Hide flow header on login * Re-roll qr code on channel expiry * Switch to AbortSignal * Improve auto-retry QR UX * Ensure we only show sign in with QR button if enabled * XXX: enable labs flag on Netlify builds * Tweak QR code sizing * Move qr login flow into a dialog to match designs * Fix null deviceId * Remove duplicate log * Iterate * Fix tests * Fix types * Fix tests * Fix tests * Make Netlify more useful * Make Netlify more useful v2 * Update copy * Refactor QR link flow to use new SDK methods Requires https://github.com/matrix-org/matrix-js-sdk/pull/5283 For element-hq/wat-internal#188 Split out from https://github.com/element-hq/element-web/pull/33184 * Link to js-sdk branch * Update tests * Simplify * Revert js-sdk linking * Iterate * Iterate * Refactor to handle most of the TODOs * Remove unused code * Remove unused code * Use js-sdk isSignInWithQRAvailable API to simplify code * Restore app-test.ts * Improve coverage * Improve coverage * Remove unused prop/state * Iterate * Fix tests * Iterate * Tests * Handle TODOs * Docs * Remove redundant call to crossSignDevice() * Workaround to remove training slash on the serverName before auto-discovery * Revert "Workaround to remove training slash on the serverName before auto-discovery" This reverts commit 0335a8fdd1b8e8d949ab7fca17c76f8fab335b58. * setLoggedIn not to be used with OIDC flows as it clears storage as per docs on setLoggedIn we should use restoreSessionFromStorage * Don't show the security_code_prompt unconditionally(i.e. for the web logging in mobile flow) * Update LoginWithQRFlow-test.tsx.snap * Update MatrixChat-test from setLoggedInSpy to restoreSessionSpy * Add todo for server switch * Add todo about handling base URL or server name * Handle server name or base URL being returned * Format * Fix loading state height * Handle the homeserver URL differing during QR code login * Comments * Comments * Register OIDC client ID after homeserver swap * Make QrLoginDialog async to minimise the impact on bundle size * Handle unsupported HS earlier in the flow * Iterate * Delint * Fix test * Discard changes to apps/web/element.io/develop/config.json --------- Co-authored-by: Hugh Nimmo-Smith <hughns@element.io> Co-authored-by: David Langley <langley.dave@gmail.com>
This commit is contained in:
committed by
GitHub
parent
d1a6137c90
commit
486fa57b68
@@ -602,9 +602,10 @@ legend {
|
||||
.mx_UnknownIdentityUsersWarningDialog button,
|
||||
.mx_ThreadActionBar button,
|
||||
.mx_HistoryActionBar button,
|
||||
[data-kind],
|
||||
[class|="maplibregl"]
|
||||
),
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton, [data-kind]),
|
||||
.mx_Dialog input[type="submit"],
|
||||
.mx_Dialog_buttons input[type="submit"] {
|
||||
@mixin mx_DialogButton;
|
||||
@@ -630,7 +631,8 @@ legend {
|
||||
.mx_UnpinAllDialog button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
.mx_UnknownIdentityUsersWarningDialog button,
|
||||
[data-kind]
|
||||
):last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
@@ -647,15 +649,16 @@ legend {
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_InviteDialog_section button,
|
||||
.mx_InviteDialog_editor button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
.mx_UnknownIdentityUsersWarningDialog button,
|
||||
[data-kind]
|
||||
):focus,
|
||||
.mx_Dialog input[type="submit"]:focus,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):focus,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton, [data-kind]):focus,
|
||||
.mx_Dialog_buttons input[type="submit"]:focus {
|
||||
filter: brightness($focus-brightness);
|
||||
}
|
||||
|
||||
.mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"]),
|
||||
.mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton, [class|="maplibregl"], [data-kind]),
|
||||
.mx_Dialog input[type="submit"].mx_Dialog_primary,
|
||||
.mx_Dialog_buttons
|
||||
button:not(
|
||||
@@ -666,7 +669,8 @@ legend {
|
||||
.mx_UnpinAllDialog button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
.mx_UnknownIdentityUsersWarningDialog button,
|
||||
[data-kind]
|
||||
),
|
||||
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
@@ -710,7 +714,8 @@ legend {
|
||||
.mx_UnpinAllDialog button,
|
||||
.mx_ShareDialog button,
|
||||
.mx_EncryptionUserSettingsTab button,
|
||||
.mx_UnknownIdentityUsersWarningDialog button
|
||||
.mx_UnknownIdentityUsersWarningDialog button,
|
||||
[data-kind]
|
||||
):disabled,
|
||||
.mx_Dialog input[type="submit"]:disabled,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton):disabled,
|
||||
|
||||
@@ -22,42 +22,23 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin-right: $spacing-12;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_LoginWithQR {
|
||||
.mx_AccessibleButton {
|
||||
display: block !important;
|
||||
}
|
||||
.mx_LoginWithQR_dialog .mx_Dialog {
|
||||
padding: var(--cpd-space-12x) var(--cpd-space-12x) var(--cpd-space-10x);
|
||||
|
||||
.mx_AccessibleButton + .mx_AccessibleButton {
|
||||
margin-top: $spacing-8;
|
||||
.mx_LoginWithQR {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
font-size: $font-15px;
|
||||
}
|
||||
|
||||
.mx_LoginWithQR {
|
||||
min-height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 500px;
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
|
||||
h1 {
|
||||
font-size: $font-24px;
|
||||
margin-bottom: 0;
|
||||
|
||||
svg {
|
||||
&.normal {
|
||||
color: $secondary-content;
|
||||
}
|
||||
&.error {
|
||||
color: $alert;
|
||||
}
|
||||
&.success {
|
||||
color: $accent;
|
||||
}
|
||||
height: 1.3em;
|
||||
margin-right: $spacing-8;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -134,34 +115,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin-bottom: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.mx_LoginWithQR_icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: var(--cpd-space-3x);
|
||||
gap: 10px;
|
||||
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
svg {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
&.mx_LoginWithQR_icon--success {
|
||||
background-color: var(--cpd-color-bg-success-subtle);
|
||||
svg {
|
||||
color: var(--cpd-color-icon-success-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_LoginWithQR_icon--critical {
|
||||
background-color: var(--cpd-color-bg-critical-subtle);
|
||||
svg {
|
||||
color: var(--cpd-color-icon-critical-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_LoginWithQR_checkCode_input {
|
||||
margin-bottom: var(--cpd-space-1x);
|
||||
text-align: initial;
|
||||
@@ -205,14 +158,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
p {
|
||||
color: $secondary-content;
|
||||
margin-bottom: var(--cpd-space-8x);
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_LoginWithQR_error .mx_LoginWithQR_main {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mx_LoginWithQR_buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+42
-16
@@ -58,7 +58,7 @@ import { Action } from "./dispatcher/actions";
|
||||
import { type OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload";
|
||||
import { SdkContextClass } from "./contexts/SDKContext";
|
||||
import { messageForLoginError } from "./utils/ErrorUtils";
|
||||
import { completeOidcLogin } from "./utils/oidc/authorize";
|
||||
import { completeOidcLogin, type CompleteOidcLoginResponse } from "./utils/oidc/authorize";
|
||||
import { getOidcErrorMessage } from "./utils/oidc/error";
|
||||
import { type OidcClientStore } from "./stores/oidc/OidcClientStore";
|
||||
import {
|
||||
@@ -302,26 +302,16 @@ async function attemptOidcNativeLogin(
|
||||
const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } =
|
||||
await completeOidcLogin(urlParams, responseMode);
|
||||
|
||||
const {
|
||||
user_id: userId,
|
||||
device_id: deviceId,
|
||||
is_guest: isGuest,
|
||||
} = await getUserIdFromAccessToken(accessToken, homeserverUrl, identityServerUrl);
|
||||
|
||||
const credentials = {
|
||||
await configureFromCompletedOAuthLogin({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
deviceId,
|
||||
userId,
|
||||
isGuest,
|
||||
};
|
||||
clientId,
|
||||
issuer,
|
||||
idToken,
|
||||
});
|
||||
|
||||
logger.debug("Logged in via OIDC native flow");
|
||||
await onSuccessfulDelegatedAuthLogin(credentials);
|
||||
// this needs to happen after success handler which clears storages
|
||||
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Failed to login via OIDC", error);
|
||||
@@ -331,6 +321,42 @@ async function attemptOidcNativeLogin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the given OIDC credentials for {@link IMatrixClientCreds}, additionally persisting them to storage.
|
||||
* @param creds the credentials from the OIDC flow
|
||||
*/
|
||||
export async function configureFromCompletedOAuthLogin({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
clientId,
|
||||
issuer,
|
||||
idToken,
|
||||
}: Omit<CompleteOidcLoginResponse, "idTokenClaims">): Promise<IMatrixClientCreds> {
|
||||
const {
|
||||
user_id: userId,
|
||||
device_id: deviceId,
|
||||
is_guest: isGuest,
|
||||
} = await getUserIdFromAccessToken(accessToken, homeserverUrl, identityServerUrl);
|
||||
|
||||
const credentials = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
deviceId,
|
||||
userId,
|
||||
isGuest,
|
||||
};
|
||||
|
||||
logger.debug("Logged in via OIDC native flow");
|
||||
await onSuccessfulDelegatedAuthLogin(credentials);
|
||||
// this needs to happen after success handler which clears storages
|
||||
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets information about the owner of a given access token.
|
||||
* @param accessToken
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type FC, useMemo, useState } from "react";
|
||||
import { RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
|
||||
import { createClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
|
||||
import LoginWithQR, { type QrLoginCredentials } from "../../../components/views/auth/LoginWithQR.tsx";
|
||||
import { Mode, Phase } from "../../../components/views/auth/LoginWithQR-types.ts";
|
||||
import BaseDialog from "../../../components/views/dialogs/BaseDialog.tsx";
|
||||
import { _t } from "../../../languageHandler.tsx";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The server config to use for QR code login
|
||||
*/
|
||||
serverConfig: ValidatedServerConfig;
|
||||
|
||||
/**
|
||||
* Handler for when the dialog is to be closed
|
||||
*/
|
||||
onFinished(this: void): void;
|
||||
|
||||
/**
|
||||
* Handler for successful completion of QR Login
|
||||
* @param credentials - the credentials to log in with
|
||||
*/
|
||||
onLoggedIn(this: void, credentials: QrLoginCredentials): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for facilitating the Login with QR flow, shown from DefaultWelcome.
|
||||
*/
|
||||
const QrLoginDialog: FC<Props> = ({ serverConfig, onLoggedIn, onFinished }) => {
|
||||
const tempClient = useMemo(() => createClient({ baseUrl: serverConfig.hsUrl }), [serverConfig]);
|
||||
|
||||
const [phase, setPhase] = useState<Phase>();
|
||||
|
||||
const hasCancel = phase === Phase.ShowingQR;
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
onFinished={onFinished}
|
||||
hasCancel={hasCancel}
|
||||
aria-label={_t("auth|sign_in_with_qr")}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<LoginWithQR
|
||||
intent={RendezvousIntent.LOGIN_ON_NEW_DEVICE}
|
||||
client={tempClient}
|
||||
onFinished={onFinished}
|
||||
onLoggedIn={onLoggedIn}
|
||||
mode={Mode.Show}
|
||||
onPhaseChange={setPhase}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrLoginDialog;
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ReactNode } from "react";
|
||||
import { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
message: string | ReactNode | null;
|
||||
@@ -18,7 +18,7 @@ interface ErrorMessageProps {
|
||||
* Reserves two lines to display errors to prevent layout shifts when the error pops up.
|
||||
*/
|
||||
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ message }) => {
|
||||
const icon = message ? <WarningIcon className="mx_Icon mx_Icon_16" /> : null;
|
||||
const icon = message ? <ErrorSolidIcon width="20px" height="20px" /> : null;
|
||||
|
||||
return (
|
||||
<div className="mx_ErrorMessage">
|
||||
|
||||
@@ -142,6 +142,8 @@ import { isOnlyAdmin } from "../../utils/membership";
|
||||
import { ModuleApi } from "../../modules/Api.ts";
|
||||
import { type IScreen } from "../../vector/routing.ts";
|
||||
import { type URLParams } from "../../vector/url_utils.ts";
|
||||
import { type QrLoginCredentials } from "../views/auth/LoginWithQR.tsx";
|
||||
import { configureFromCompletedOAuthLogin } from "../../Lifecycle";
|
||||
|
||||
// legacy export
|
||||
export { default as Views } from "../../Views";
|
||||
@@ -827,6 +829,26 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.viewSomethingBehindModal();
|
||||
break;
|
||||
}
|
||||
case Action.ViewQrLogin: {
|
||||
if (this.isLoggedInViewPageDisplayed()) {
|
||||
logger.warn("Ignoring payload due to unexpected call outside auth flows", payload);
|
||||
} else {
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../async-components/views/dialogs/QrLoginDialog")),
|
||||
{
|
||||
serverConfig: this.getServerProperties().serverConfig,
|
||||
onLoggedIn: this.onUserCompletedQrLoginFlow,
|
||||
},
|
||||
"mx_LoginWithQR_dialog",
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
// View the welcome or home page if we need something to look at
|
||||
this.viewSomethingBehindModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "view_welcome_page":
|
||||
this.viewWelcome();
|
||||
break;
|
||||
@@ -1858,6 +1880,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
params: params,
|
||||
});
|
||||
PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN);
|
||||
} else if (screen === "qr_login") {
|
||||
dis.fire(Action.ViewQrLogin);
|
||||
} else if (screen === "forgot_password") {
|
||||
dis.dispatch({
|
||||
action: "start_password_recovery",
|
||||
@@ -2131,6 +2155,36 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
|
||||
};
|
||||
|
||||
/**
|
||||
* After successful qr login, load & persist the credentials, as well as the secrets bundle.
|
||||
*/
|
||||
private onUserCompletedQrLoginFlow = async ({
|
||||
secrets,
|
||||
deviceId,
|
||||
...tokenResponse
|
||||
}: QrLoginCredentials): Promise<void> => {
|
||||
// Persist credentials + OIDC settings, then hydrate the client from storage.
|
||||
// setLoggedIn would clear storage and drop the OIDC settings; see its docstring.
|
||||
await configureFromCompletedOAuthLogin(tokenResponse);
|
||||
await Lifecycle.restoreSessionFromStorage();
|
||||
|
||||
if (secrets) {
|
||||
const crypto = MatrixClientPeg.safeGet().getCrypto();
|
||||
if (crypto?.importSecretsBundle) {
|
||||
// This imports the secrets and cross-signs the device in one go
|
||||
await crypto.importSecretsBundle(secrets);
|
||||
} else {
|
||||
logger.warn(
|
||||
"Crypto not initialised or no importSecretsBundle() method, cannot import secrets from QR login",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn("No secrets received from QR login");
|
||||
}
|
||||
|
||||
this.onShowPostLoginScreen();
|
||||
};
|
||||
|
||||
/** Called when {@link Views.E2E_SETUP} or {@link Views.COMPLETE_SECURITY} have completed. */
|
||||
private onCompleteSecurityE2eSetupFinished = async (): Promise<void> => {
|
||||
const forceVerify = await this.shouldForceVerification();
|
||||
@@ -2220,7 +2274,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
);
|
||||
}
|
||||
} else if (this.state.view === Views.WELCOME) {
|
||||
view = <Welcome />;
|
||||
view = <Welcome {...this.getServerProperties()} />;
|
||||
} else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) {
|
||||
const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail;
|
||||
view = (
|
||||
|
||||
@@ -5,15 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { type JSX } from "react";
|
||||
import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||
import { createClient } from "matrix-js-sdk/src/matrix";
|
||||
import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig.ts";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg.ts";
|
||||
import { isElementBranded } from "../../../branding.ts";
|
||||
import { useFeatureEnabled } from "../../../hooks/useSettings.ts";
|
||||
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig.ts";
|
||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo.ts";
|
||||
import Spinner from "../elements/Spinner.tsx";
|
||||
|
||||
const DefaultWelcome: React.FC = () => {
|
||||
interface Props {
|
||||
/**
|
||||
* The server config to use for QR code login
|
||||
*/
|
||||
serverConfig: ValidatedServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default welcome (/#/welcome) screen to render if not overridden by config.json
|
||||
* Links out to Login, Registration, QR Login & Directory (if guest access is supported)
|
||||
*/
|
||||
const DefaultWelcome: React.FC<Props> = ({ serverConfig }) => {
|
||||
const brand = SdkConfig.get("brand");
|
||||
const branding = SdkConfig.getObject("branding");
|
||||
const logoUrl = branding.get("auth_header_logo_url");
|
||||
@@ -21,6 +38,45 @@ const DefaultWelcome: React.FC = () => {
|
||||
const showGuestFunctions = !!MatrixClientPeg.get();
|
||||
const isElement = isElementBranded();
|
||||
|
||||
const isQrLoginEnabled = useFeatureEnabled("feature_login_with_qr");
|
||||
const showQrButton = useAsyncMemo(async () => {
|
||||
if (!isQrLoginEnabled) return false;
|
||||
const tempClient = createClient({
|
||||
baseUrl: serverConfig.hsUrl,
|
||||
});
|
||||
// We do not use isSignInWithQRAvailable as we only need rendezvous at this point and are likely to be logging
|
||||
// into a different server than this one, so whether this one supports DAG is irrelevant.
|
||||
return tempClient.doesServerSupportUnstableFeature("org.matrix.msc4108");
|
||||
}, [serverConfig, isQrLoginEnabled]);
|
||||
|
||||
const loading = isQrLoginEnabled && showQrButton === undefined;
|
||||
|
||||
let body: JSX.Element;
|
||||
if (loading) {
|
||||
body = <Spinner />;
|
||||
} else {
|
||||
body = (
|
||||
<div className="mx_DefaultWelcome_buttons">
|
||||
{showQrButton && (
|
||||
<Button as="a" href="#/qr_login" kind="primary" size="md" Icon={QrCodeIcon}>
|
||||
{_t("auth|sign_in_with_qr")}
|
||||
</Button>
|
||||
)}
|
||||
<Button as="a" href="#/login" kind="primary" size="md">
|
||||
{showQrButton ? _t("auth|sign_in_manually") : _t("action|sign_in")}
|
||||
</Button>
|
||||
<Button as="a" href="#/register" kind="secondary" size="md">
|
||||
{_t("action|create_account")}
|
||||
</Button>
|
||||
{showGuestFunctions && (
|
||||
<Button as="a" href="#/directory" kind="tertiary" size="md">
|
||||
{_t("action|explore_rooms")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_DefaultWelcome">
|
||||
<a href={branding.get("logo_link_url")} target="_blank" rel="noopener" className="mx_DefaultWelcome_logo">
|
||||
@@ -31,19 +87,7 @@ const DefaultWelcome: React.FC = () => {
|
||||
</Heading>
|
||||
{isElement && <Text size="md">{_t("welcome|tagline_element")}</Text>}
|
||||
|
||||
<div className="mx_DefaultWelcome_buttons">
|
||||
<Button as="a" href="#/login" kind="primary" size="md">
|
||||
{_t("action|sign_in")}
|
||||
</Button>
|
||||
<Button as="a" href="#/register" kind="secondary" size="md">
|
||||
{_t("action|create_account")}
|
||||
</Button>
|
||||
{showGuestFunctions && (
|
||||
<Button as="a" href="#/directory" kind="tertiary" size="md">
|
||||
{_t("action|explore_rooms")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,10 +26,24 @@ export enum Phase {
|
||||
Error,
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing the click actions (transitions) a user can take during the login with QR flow
|
||||
*/
|
||||
export enum Click {
|
||||
/**
|
||||
* Cancel the flow
|
||||
*/
|
||||
Cancel,
|
||||
/**
|
||||
* A specific case of cancellation when the existing device declines the login
|
||||
*/
|
||||
Decline,
|
||||
/**
|
||||
* Approve the login from the existing device
|
||||
*/
|
||||
Approve,
|
||||
Back,
|
||||
/**
|
||||
* Initialise the flow & show QR code
|
||||
*/
|
||||
ShowQr,
|
||||
}
|
||||
|
||||
@@ -15,27 +15,112 @@ import {
|
||||
RendezvousError,
|
||||
type RendezvousFailureReason,
|
||||
RendezvousIntent,
|
||||
signInByGeneratingQR,
|
||||
} from "matrix-js-sdk/src/rendezvous";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { AutoDiscovery, MatrixClient, OAuthGrantType, type OidcClientConfig, type XOR } from "matrix-js-sdk/src/matrix";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import { Click, Mode, Phase } from "./LoginWithQR-types";
|
||||
import LoginWithQRFlow from "./LoginWithQRFlow";
|
||||
import { type CompleteOidcLoginResponse } from "../../../utils/oidc/authorize";
|
||||
import { getOidcClientId } from "../../../utils/oidc/registerClient.ts";
|
||||
import SdkConfig from "../../../SdkConfig.ts";
|
||||
|
||||
interface IProps {
|
||||
export type QrLoginCredentials = Omit<CompleteOidcLoginResponse, "idTokenClaims"> &
|
||||
Awaited<ReturnType<MSC4108SignInWithQR["shareSecrets"]>> & {
|
||||
deviceId: string;
|
||||
};
|
||||
|
||||
type BaseProps = {
|
||||
/**
|
||||
* The MatrixClient to use for the rendezvous communication with the other device.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/**
|
||||
* Whether to show a QR code or facilitate scanning one. Only Mode.Show is currently supported.
|
||||
*/
|
||||
mode: Mode;
|
||||
onFinished(...args: any): void;
|
||||
}
|
||||
/**
|
||||
* Callback when the internal phase state has changed
|
||||
* @param phase - the new phase which is being entered
|
||||
*/
|
||||
onPhaseChange?(phase: Phase): void;
|
||||
/**
|
||||
* Callback when the flow is concluded
|
||||
* @param success - whether it was successful
|
||||
*/
|
||||
onFinished(this: void, success?: boolean): void;
|
||||
};
|
||||
|
||||
type Props = XOR<
|
||||
{
|
||||
/**
|
||||
* Intent to facilitate logging into this device from an existing device
|
||||
*/
|
||||
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE;
|
||||
/**
|
||||
* Callback for successful login
|
||||
* @param credentials - the credentials to authenticate with
|
||||
*/
|
||||
onLoggedIn(credentials: QrLoginCredentials): Promise<void>;
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Intent to facilitate logging into another device from this existing device
|
||||
*/
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
|
||||
}
|
||||
> &
|
||||
BaseProps;
|
||||
|
||||
interface IState {
|
||||
/**
|
||||
* The current phase of the flow
|
||||
*/
|
||||
phase: Phase;
|
||||
/**
|
||||
* The rendezvous channel in use
|
||||
*/
|
||||
rendezvous?: MSC4108SignInWithQR;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
verificationUri?: string;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
userCode?: string;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
checkCode?: string;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
failureReason?: FailureReason;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
loginServerDetails?: {
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
homeserverUrl: string;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
identityServerUrl?: string;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
metadata: OidcClientConfig;
|
||||
/**
|
||||
* TODO
|
||||
*/
|
||||
clientId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export enum LoginWithQRFailureReason {
|
||||
@@ -45,34 +130,67 @@ export enum LoginWithQRFailureReason {
|
||||
|
||||
export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason;
|
||||
|
||||
/**
|
||||
* Resolve a server name or baseURL to the homeserver & identity server URLs.
|
||||
* @param serverNameOrBaseUrl the name or URL to resolve
|
||||
* Whilst the 2024 version of MSC4108 says that we always get a server name, in practise the
|
||||
* rust-sdk is currently misbehaving and we may receive a base URL instead. Additionally, the 2025
|
||||
* version of MSC4108 will always give the base URL.
|
||||
* As such, we should be resilient and support both formats until the spec and implementations have
|
||||
* stabilised.
|
||||
*/
|
||||
async function resolveServerURLs(
|
||||
serverNameOrBaseUrl: string,
|
||||
): Promise<Pick<Partial<NonNullable<IState["loginServerDetails"]>>, "homeserverUrl" | "identityServerUrl">> {
|
||||
if (serverNameOrBaseUrl.startsWith("http://") || serverNameOrBaseUrl.startsWith("https://")) {
|
||||
// treat as base URL and skip discovery
|
||||
return {
|
||||
homeserverUrl: serverNameOrBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// treat as server name and do discovery
|
||||
const clientConfig = await AutoDiscovery.findClientConfig(serverNameOrBaseUrl);
|
||||
const homeserverUrl = clientConfig?.["m.homeserver"]?.base_url ?? undefined;
|
||||
|
||||
const identityServerUrl = clientConfig?.["m.identity_server"]?.base_url ?? undefined;
|
||||
|
||||
return {
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that allows sign in and E2EE set up with a QR code.
|
||||
*
|
||||
* It implements `login.reciprocate` capabilities and showing QR codes.
|
||||
* It implements `login.reciprocate` & `login.start` capabilities and showing QR codes.
|
||||
* It does not implement any flows requiring the scanning of QR codes.
|
||||
*
|
||||
* This uses the unstable feature of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
|
||||
* Implements the v2024 version of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
|
||||
*/
|
||||
export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
export default class LoginWithQR extends React.Component<Props, IState> {
|
||||
private finished = false;
|
||||
private abortController?: AbortController;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
phase: Phase.Loading,
|
||||
};
|
||||
}
|
||||
|
||||
private get ourIntent(): RendezvousIntent {
|
||||
return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
|
||||
this.props.onPhaseChange?.(this.state.phase);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
void this.updateMode(this.props.mode);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
||||
public componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<IState>): void {
|
||||
if (prevState.phase !== this.state.phase) {
|
||||
this.props.onPhaseChange?.(this.state.phase);
|
||||
}
|
||||
|
||||
if (prevProps.mode !== this.props.mode) {
|
||||
void this.updateMode(this.props.mode);
|
||||
}
|
||||
@@ -99,13 +217,19 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
|
||||
private onFinished(success: boolean): void {
|
||||
this.finished = true;
|
||||
if (!success) {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
this.props.onFinished(success);
|
||||
}
|
||||
|
||||
private generateAndShowCode = async (abortController: AbortController): Promise<void> => {
|
||||
let rendezvous: MSC4108SignInWithQR;
|
||||
try {
|
||||
rendezvous = await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
|
||||
rendezvous =
|
||||
this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
|
||||
? await signInByGeneratingQR(this.props.client, this.onFailure, abortController.signal)
|
||||
: await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
|
||||
if (abortController.signal.aborted) return;
|
||||
this.setState({
|
||||
phase: Phase.ShowingQR,
|
||||
@@ -120,7 +244,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
|
||||
if (this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
|
||||
// MSC4108-Flow: NewScanned
|
||||
await rendezvous.negotiateProtocols();
|
||||
const { verificationUri } = await rendezvous.deviceAuthorizationGrant();
|
||||
@@ -128,6 +252,43 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
verificationUri,
|
||||
});
|
||||
} else {
|
||||
const { serverName } = await rendezvous.negotiateProtocols();
|
||||
const { homeserverUrl, identityServerUrl } = await resolveServerURLs(serverName!);
|
||||
|
||||
if (!homeserverUrl) {
|
||||
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
|
||||
logger.error("Failed to discover homeserver URL");
|
||||
throw new Error("Failed to discover homeserver URL");
|
||||
}
|
||||
|
||||
let metadata: OidcClientConfig;
|
||||
let clientId: string;
|
||||
try {
|
||||
// Create a new client as the homeserver URL may not be the same as we used for the secure channel
|
||||
metadata = await new MatrixClient({ baseUrl: homeserverUrl }).getAuthMetadata();
|
||||
if (!metadata.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
|
||||
throw new Error("Server does not support Device Authorization Grant");
|
||||
}
|
||||
clientId = await getOidcClientId(metadata, SdkConfig.get().oidc_static_clients);
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
phase: Phase.Error,
|
||||
failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport,
|
||||
});
|
||||
logger.error("Failed to register OIDC Client ID", e);
|
||||
throw new Error("Failed to register OIDC Client ID", { cause: e });
|
||||
}
|
||||
|
||||
this.setState({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
loginServerDetails: {
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
metadata,
|
||||
clientId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// we ask the user to confirm that the channel is secure
|
||||
@@ -152,7 +313,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
|
||||
if (this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
|
||||
// MSC4108-Flow: NewScanned
|
||||
this.setState({ phase: Phase.Loading });
|
||||
|
||||
@@ -168,8 +329,41 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
// 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");
|
||||
if (!this.state.loginServerDetails) {
|
||||
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
|
||||
throw new Error("Server details not found in state");
|
||||
}
|
||||
|
||||
const { homeserverUrl, identityServerUrl, clientId, metadata } = this.state.loginServerDetails;
|
||||
|
||||
// Generate our new device ID
|
||||
const deviceId = secureRandomString(10);
|
||||
const { userCode } = await this.state.rendezvous.deviceAuthorizationGrant({
|
||||
metadata,
|
||||
clientId,
|
||||
deviceId,
|
||||
});
|
||||
this.setState({ phase: Phase.WaitingForDevice, userCode });
|
||||
|
||||
const tokenResponse = await this.state.rendezvous.completeLoginOnNewDevice({ clientId });
|
||||
|
||||
if (tokenResponse) {
|
||||
const { secrets } = await this.state.rendezvous.shareSecrets();
|
||||
|
||||
await this.props.onLoggedIn({
|
||||
accessToken: tokenResponse.access_token,
|
||||
refreshToken: tokenResponse.refresh_token,
|
||||
homeserverUrl,
|
||||
clientId,
|
||||
idToken: tokenResponse.id_token,
|
||||
issuer: metadata!.issuer,
|
||||
identityServerUrl,
|
||||
secrets,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
this.onFinished(true);
|
||||
}
|
||||
}
|
||||
} catch (e: RendezvousError | unknown) {
|
||||
logger.error("Error whilst approving sign in", e);
|
||||
@@ -182,7 +376,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
|
||||
private onFailure = async (reason: RendezvousFailureReason): Promise<void> => {
|
||||
if (this.state.phase === Phase.Error) return; // Already in failed state
|
||||
logger.info(`Rendezvous failed: ${reason}`);
|
||||
logger.warn(`Rendezvous failed: ${reason}`);
|
||||
|
||||
// Generate a new rendezvous channel & qr code if we hit expiry whilst still showing the QR code
|
||||
if (reason === ClientRendezvousFailureReason.Expired && this.state.phase === Phase.ShowingQR) {
|
||||
@@ -196,7 +390,6 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
logger.warn("Failed to re-roll qr code on expiry", e);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ phase: Phase.Error, failureReason: reason });
|
||||
};
|
||||
|
||||
@@ -207,7 +400,6 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
verificationUri: undefined,
|
||||
failureReason: undefined,
|
||||
userCode: undefined,
|
||||
checkCode: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,19 +407,17 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
switch (type) {
|
||||
case Click.Cancel:
|
||||
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
|
||||
this.reset();
|
||||
this.onFinished(false);
|
||||
break;
|
||||
case Click.Approve:
|
||||
await this.approveLogin(checkCode);
|
||||
break;
|
||||
case Click.Decline:
|
||||
await this.state.rendezvous?.declineLoginOnExistingDevice();
|
||||
this.reset();
|
||||
this.onFinished(false);
|
||||
break;
|
||||
case Click.Back:
|
||||
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
|
||||
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
|
||||
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
|
||||
} else {
|
||||
await this.state.rendezvous?.declineLoginOnExistingDevice();
|
||||
}
|
||||
this.onFinished(false);
|
||||
break;
|
||||
case Click.ShowQr:
|
||||
@@ -244,7 +434,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
|
||||
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
|
||||
failureReason={this.state.failureReason}
|
||||
userCode={this.state.userCode}
|
||||
checkCode={this.state.checkCode}
|
||||
intent={this.props.intent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, createRef, type ReactNode } from "react";
|
||||
import { ClientRendezvousFailureReason, MSC4108FailureReason } from "matrix-js-sdk/src/rendezvous";
|
||||
import React, { type ComponentProps, createRef, type JSX, type ReactNode } from "react";
|
||||
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
|
||||
import ChevronLeftIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left";
|
||||
import CheckCircleSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
|
||||
import { Heading, MFAInput, Text } from "@vector-im/compound-web";
|
||||
import { BigIcon, Button, Heading, MFAInput, Text } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { LockSolidIcon, MobileIcon, QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
@@ -25,18 +25,38 @@ import { type FailureReason, LoginWithQRFailureReason } from "./LoginWithQR";
|
||||
import { ErrorMessage } from "../../structures/ErrorMessage";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The phase the flow is to be rendered in
|
||||
*/
|
||||
phase: Phase;
|
||||
/**
|
||||
* The QR code data to render in Phase.ShowingQR
|
||||
*/
|
||||
code?: Uint8Array;
|
||||
/**
|
||||
* The intent mode of the flow (login or reciprocate).
|
||||
*/
|
||||
intent: RendezvousIntent;
|
||||
/**
|
||||
* State transition handler
|
||||
* @param type the type of transition to take
|
||||
* @param checkCodeEntered the check code entered, only present for Click.Approve
|
||||
*/
|
||||
onClick(type: Click, checkCodeEntered?: string): Promise<void>;
|
||||
/**
|
||||
* The failure reason to render in Phase.Error
|
||||
*/
|
||||
failureReason?: FailureReason;
|
||||
/**
|
||||
* The 6 digit user code to render in Phase.WaitingForDevice
|
||||
*/
|
||||
userCode?: string;
|
||||
checkCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 by rendering a QR code in both login & reciprocate directions.
|
||||
*
|
||||
* This supports the unstable features of MSC4108
|
||||
* Implements the v2024 version of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
|
||||
*/
|
||||
export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
private checkCodeInput = createRef<HTMLInputElement>();
|
||||
@@ -49,9 +69,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
};
|
||||
|
||||
private cancelButton = (): JSX.Element => (
|
||||
<AccessibleButton data-testid="cancel-button" kind="primary_outline" onClick={this.handleClick(Click.Cancel)}>
|
||||
<Button data-testid="cancel-button" kind="primary" size="lg" onClick={this.handleClick(Click.Cancel)}>
|
||||
{_t("action|cancel")}
|
||||
</AccessibleButton>
|
||||
</Button>
|
||||
);
|
||||
|
||||
private simpleSpinner = (description?: string): JSX.Element => {
|
||||
@@ -74,9 +94,16 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
switch (this.props.phase) {
|
||||
case Phase.Error: {
|
||||
backButton = false;
|
||||
buttons = (
|
||||
<Button kind="primary" size="lg" onClick={this.handleClick(Click.ShowQr)}>
|
||||
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
|
||||
? _t("auth|qr_code_login|start_over")
|
||||
: _t("action|try_again")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
let Icon = ErrorIcon;
|
||||
let success: boolean | null = false;
|
||||
let iconKind: ComponentProps<typeof BigIcon>["kind"] = "critical";
|
||||
let title: string | undefined;
|
||||
let message: ReactNode | undefined;
|
||||
|
||||
@@ -89,6 +116,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
case MSC4108FailureReason.UserCancelled:
|
||||
title = _t("auth|qr_code_login|error_user_cancelled_title");
|
||||
message = _t("auth|qr_code_login|error_user_cancelled");
|
||||
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
|
||||
buttons = this.cancelButton();
|
||||
}
|
||||
break;
|
||||
|
||||
case MSC4108FailureReason.AuthorizationExpired:
|
||||
@@ -116,7 +146,7 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
break;
|
||||
|
||||
case ClientRendezvousFailureReason.OtherDeviceAlreadySignedIn:
|
||||
success = true;
|
||||
iconKind = "success";
|
||||
Icon = CheckCircleSolidIcon;
|
||||
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");
|
||||
@@ -125,6 +155,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
case ClientRendezvousFailureReason.UserDeclined:
|
||||
title = _t("auth|qr_code_login|error_user_declined_title");
|
||||
message = _t("auth|qr_code_login|error_user_declined");
|
||||
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
|
||||
buttons = this.cancelButton();
|
||||
}
|
||||
break;
|
||||
|
||||
case LoginWithQRFailureReason.RateLimited:
|
||||
@@ -138,11 +171,13 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
break;
|
||||
|
||||
case ClientRendezvousFailureReason.HomeserverLacksSupport:
|
||||
success = null;
|
||||
Icon = QrCodeIcon;
|
||||
backButton = true;
|
||||
title = _t("auth|qr_code_login|unsupported_heading");
|
||||
message = _t("auth|qr_code_login|unsupported_explainer");
|
||||
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
|
||||
buttons = this.cancelButton();
|
||||
}
|
||||
break;
|
||||
|
||||
case MSC4108FailureReason.DeviceAlreadyExists:
|
||||
@@ -158,14 +193,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
className = "mx_LoginWithQR_error";
|
||||
main = (
|
||||
<>
|
||||
<div
|
||||
className={classNames("mx_LoginWithQR_icon", {
|
||||
"mx_LoginWithQR_icon--critical": success === false,
|
||||
"mx_LoginWithQR_icon--success": success === true,
|
||||
})}
|
||||
>
|
||||
<Icon width="32px" height="32px" />
|
||||
</div>
|
||||
<BigIcon kind={iconKind}>
|
||||
<Icon />
|
||||
</BigIcon>
|
||||
<Heading as="h1" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
@@ -178,6 +208,11 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
backButton = false;
|
||||
main = (
|
||||
<>
|
||||
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE && (
|
||||
<BigIcon>
|
||||
<MobileIcon />
|
||||
</BigIcon>
|
||||
)}
|
||||
<Heading as="h1" size="sm" weight="semibold">
|
||||
{_t("auth|qr_code_login|check_code_heading")}
|
||||
</Heading>
|
||||
@@ -209,39 +244,62 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<AccessibleButton
|
||||
<Button
|
||||
data-testid="approve-login-button"
|
||||
kind="primary"
|
||||
size="lg"
|
||||
onClick={this.handleClick(Click.Approve)}
|
||||
>
|
||||
{_t("action|continue")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="decline-login-button"
|
||||
kind="primary_outline"
|
||||
kind="tertiary"
|
||||
size="lg"
|
||||
onClick={this.handleClick(Click.Decline)}
|
||||
>
|
||||
{_t("action|cancel")}
|
||||
</AccessibleButton>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case Phase.ShowingQR: {
|
||||
const steps = [
|
||||
_t("auth|qr_code_login|open_element_other_device", {
|
||||
brand: SdkConfig.get().brand,
|
||||
}),
|
||||
_t("auth|qr_code_login|select_qr_code", {
|
||||
scanQRCode: <strong>{_t("auth|qr_code_login|scan_qr_code")}</strong>,
|
||||
}),
|
||||
_t("auth|qr_code_login|point_the_camera"),
|
||||
_t("auth|qr_code_login|follow_remaining_instructions"),
|
||||
];
|
||||
let steps: ReactNode[];
|
||||
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
|
||||
steps = [
|
||||
_t("auth|qr_code_login|open_element_mobile_device", {
|
||||
brand: SdkConfig.get().brand,
|
||||
}),
|
||||
_t("auth|qr_code_login|tap_avatar_link_new_device", {
|
||||
linkNewDevice: <strong>{_t("settings|sessions|sign_in_with_qr")}</strong>,
|
||||
}),
|
||||
_t("auth|qr_code_login|choose_desktop_computer", {
|
||||
desktopComputer: <strong>{_t("auth|qr_code_login|desktop_computer")}</strong>,
|
||||
}),
|
||||
_t("auth|qr_code_login|select_ready_to_scan", {
|
||||
readyToScan: <strong>{_t("auth|qr_code_login|ready_to_scan")}</strong>,
|
||||
}),
|
||||
_t("auth|qr_code_login|follow_remaining_instructions"),
|
||||
];
|
||||
} else {
|
||||
steps = [
|
||||
_t("auth|qr_code_login|open_element_other_device", {
|
||||
brand: SdkConfig.get().brand,
|
||||
}),
|
||||
_t("auth|qr_code_login|select_qr_code", {
|
||||
scanQRCode: <strong>{_t("auth|qr_code_login|scan_qr_code")}</strong>,
|
||||
}),
|
||||
_t("auth|qr_code_login|point_the_camera"),
|
||||
_t("auth|qr_code_login|follow_remaining_instructions"),
|
||||
];
|
||||
}
|
||||
|
||||
main = (
|
||||
<>
|
||||
<Heading as="h1" size="sm" weight="semibold">
|
||||
{_t("auth|qr_code_login|scan_code_instruction")}
|
||||
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
|
||||
? _t("auth|qr_code_login|scan_code_instruction")
|
||||
: _t("auth|qr_code_login|scan_code_instruction_reciprocate")}
|
||||
</Heading>
|
||||
<div className="mx_LoginWithQR_qrWrapper">
|
||||
{this.props.code ? (
|
||||
@@ -252,7 +310,7 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
</div>
|
||||
<ol>
|
||||
{steps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
<li key={this.props.intent + i}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
@@ -263,18 +321,27 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
main = this.simpleSpinner();
|
||||
break;
|
||||
case Phase.WaitingForDevice:
|
||||
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}
|
||||
</>
|
||||
);
|
||||
main =
|
||||
this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE ? (
|
||||
<>
|
||||
<BigIcon>
|
||||
<LockSolidIcon />
|
||||
</BigIcon>
|
||||
<Heading as="h1" size="sm" weight="semibold">
|
||||
{_t("auth|qr_code_login|security_code_title")}
|
||||
</Heading>
|
||||
<Text size="md">{_t("auth|qr_code_login|security_code_prompt")}</Text>
|
||||
<MFAInput
|
||||
className="mx_LoginWithQR_checkCode_input mx_no_textinput"
|
||||
length={6}
|
||||
value={this.props.userCode}
|
||||
disabled
|
||||
/>
|
||||
{this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))}
|
||||
</>
|
||||
) : (
|
||||
this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))
|
||||
);
|
||||
buttons = this.cancelButton();
|
||||
break;
|
||||
case Phase.Verifying:
|
||||
@@ -284,12 +351,12 @@ export default class LoginWithQRFlow extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<div data-testid="login-with-qr" className={classNames("mx_LoginWithQR", className)}>
|
||||
{backButton ? (
|
||||
{this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE && backButton ? (
|
||||
<div className="mx_LoginWithQR_heading">
|
||||
<AccessibleButton
|
||||
data-testid="back-button"
|
||||
className="mx_LoginWithQR_BackButton"
|
||||
onClick={this.handleClick(Click.Back)}
|
||||
onClick={this.handleClick(Click.Cancel)}
|
||||
title={_t("action|back")}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { type ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
import { Glass } from "@vector-im/compound-web";
|
||||
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
@@ -18,8 +17,13 @@ import LanguageSelector from "./LanguageSelector";
|
||||
import EmbeddedPage from "../../structures/EmbeddedPage";
|
||||
import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars";
|
||||
import DefaultWelcome from "./DefaultWelcome.tsx";
|
||||
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig.ts";
|
||||
|
||||
export default class Welcome extends React.PureComponent<EmptyObject> {
|
||||
interface Props {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
}
|
||||
|
||||
export default class Welcome extends React.PureComponent<Props> {
|
||||
public render(): React.ReactNode {
|
||||
const pagesConfig = SdkConfig.getObject("embedded_pages");
|
||||
const pageUrl = pagesConfig?.get("welcome_url");
|
||||
@@ -36,7 +40,7 @@ export default class Welcome extends React.PureComponent<EmptyObject> {
|
||||
if (pageUrl) {
|
||||
body = <EmbeddedPage className="mx_WelcomePage" url={pageUrl} replaceMap={replaceMap} />;
|
||||
} else {
|
||||
body = <DefaultWelcome />;
|
||||
body = <DefaultWelcome serverConfig={this.props.serverConfig} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,9 +59,10 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
private getSettingValue(): boolean {
|
||||
// If a level defined in props is overridden by a level at a high presedence, it gets disabled
|
||||
// and we should show the overridding value.
|
||||
// If a level defined in props is overridden by a level at a high precedence,
|
||||
// or the setting lacks support for the desired level, it gets disabled and we should show the overriding value.
|
||||
if (
|
||||
!SettingsStore.doesSettingSupportLevel(this.props.name, this.props.level) ||
|
||||
SettingsStore.settingIsOveriddenAtConfigLevel(this.props.name, this.props.roomId ?? null, this.props.level)
|
||||
) {
|
||||
return !!SettingsStore.getValue(this.props.name);
|
||||
|
||||
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React, { lazy, Suspense, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
|
||||
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import Modal from "../../../../../Modal";
|
||||
@@ -263,7 +264,12 @@ const SessionManagerTab: React.FC<{
|
||||
if (signInWithQrMode) {
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<LoginWithQR mode={signInWithQrMode} onFinished={onQrFinish} client={matrixClient} />
|
||||
<LoginWithQR
|
||||
mode={signInWithQrMode}
|
||||
onFinished={onQrFinish}
|
||||
client={matrixClient}
|
||||
intent={RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ export enum Action {
|
||||
*/
|
||||
ViewUserDeviceSettings = "view_user_device_settings",
|
||||
|
||||
/**
|
||||
* Opens the QR login flow. Only valid during authentication. No additional payload information required.
|
||||
*/
|
||||
ViewQrLogin = "view_qr_login",
|
||||
|
||||
/**
|
||||
* Opens the room directory. No additional payload information required.
|
||||
*/
|
||||
|
||||
@@ -246,7 +246,9 @@
|
||||
"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",
|
||||
"choose_desktop_computer": "Choose \"%(desktopComputer)s\"",
|
||||
"completing_setup": "Completing set up of your new device",
|
||||
"desktop_computer": "Desktop computer",
|
||||
"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_title": "The sign in was not completed in time",
|
||||
@@ -267,16 +269,22 @@
|
||||
"error_user_declined": "You or the account provider declined the sign in request.",
|
||||
"error_user_declined_title": "Sign in declined",
|
||||
"follow_remaining_instructions": "Follow the remaining instructions",
|
||||
"open_element_mobile_device": "Open %(brand)s on your mobile device",
|
||||
"open_element_other_device": "Open %(brand)s on your other device",
|
||||
"point_the_camera": "Scan the QR code shown here",
|
||||
"scan_code_instruction": "Scan the QR code with another device",
|
||||
"ready_to_scan": "Ready to scan",
|
||||
"scan_code_instruction": "Scan the QR code",
|
||||
"scan_code_instruction_reciprocate": "Scan the QR code with another device",
|
||||
"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.",
|
||||
"security_code_prompt": "Your account provider may ask for the following code to verify the sign in.",
|
||||
"security_code_title": "Your verification code",
|
||||
"select_qr_code": "Select \"%(scanQRCode)s\"",
|
||||
"select_ready_to_scan": "Select \"%(readyToScan)s\" and scan the QR code shown here",
|
||||
"start_over": "Start over",
|
||||
"tap_avatar_link_new_device": "Tap your avatar and select \"%(linkNewDevice)s\"",
|
||||
"unsupported_explainer": "Your account provider doesn't support signing into a new device with a QR code.",
|
||||
"unsupported_heading": "QR code not supported",
|
||||
"waiting_for_device": "Waiting for device to sign in"
|
||||
"waiting_for_device": "Waiting for your other device"
|
||||
},
|
||||
"register_action": "Create Account",
|
||||
"registration": {
|
||||
@@ -335,8 +343,10 @@
|
||||
"sign_in_description": "Use your account to continue.",
|
||||
"sign_in_instead": "Sign in instead",
|
||||
"sign_in_instead_prompt": "Already have an account? <a>Sign in here</a>",
|
||||
"sign_in_manually": "Sign in manually",
|
||||
"sign_in_or_register": "Sign In or Create Account",
|
||||
"sign_in_or_register_description": "Use your account or create a new one to continue.",
|
||||
"sign_in_with_qr": "Sign in with QR code",
|
||||
"sign_in_with_sso": "Sign in with single sign-on",
|
||||
"signing_in": "Signing In…",
|
||||
"soft_logout": {
|
||||
@@ -1518,6 +1528,7 @@
|
||||
"bridge_state_manager": "This bridge is managed by <user />.",
|
||||
"bridge_state_workspace": "Workspace: <networkLink/>",
|
||||
"click_for_info": "Click for more info",
|
||||
"config_only": "Can only be enabled via config.json.",
|
||||
"currently_experimental": "Currently experimental.",
|
||||
"custom_themes": "Support adding custom themes",
|
||||
"dynamic_room_predecessors": "Dynamic room predecessors",
|
||||
@@ -1562,6 +1573,7 @@
|
||||
"leave_beta_reload": "Leaving the beta will reload %(brand)s.",
|
||||
"location_share_live": "Live Location Sharing",
|
||||
"location_share_live_description": "Temporary implementation. Locations persist in room history.",
|
||||
"login_with_qr": "Log in with QR code",
|
||||
"mjolnir": "New ways to ignore people",
|
||||
"msc3531_hide_messages_pending_moderation": "Let moderators hide messages pending moderation.",
|
||||
"new_room_list": "Enable new room list",
|
||||
|
||||
@@ -230,6 +230,7 @@ export interface Settings {
|
||||
"feature_notifications": IFeature;
|
||||
"feature_msc4362_encrypted_state_events": IFeature;
|
||||
"feature_user_status": IFeature;
|
||||
"feature_login_with_qr": IFeature;
|
||||
// These are in the feature namespace but aren't actually features
|
||||
"feature_hidebold": IBaseSetting<boolean>;
|
||||
|
||||
@@ -674,6 +675,14 @@ export const SETTINGS: Settings = {
|
||||
default: false,
|
||||
controller: new ReloadOnChangeController(),
|
||||
},
|
||||
"feature_login_with_qr": {
|
||||
supportedLevels: [SettingLevel.CONFIG],
|
||||
labsGroup: LabGroup.Ui,
|
||||
displayName: _td("labs|login_with_qr"),
|
||||
description: _td("labs|config_only"),
|
||||
isFeature: true,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* With the transition to Compound we are moving to a base font size
|
||||
* of 16px. We're taking the opportunity to move away from the `baseFontSize`
|
||||
|
||||
@@ -76,24 +76,45 @@ const getCodeAndStateFromParams = (
|
||||
return { code, state };
|
||||
};
|
||||
|
||||
type CompleteOidcLoginResponse = {
|
||||
// url of the homeserver selected during login
|
||||
/**
|
||||
* Return type for {@link completeOidcLogin}
|
||||
* Contains all the credentials gathered from a successful OIDC login
|
||||
*/
|
||||
export type CompleteOidcLoginResponse = {
|
||||
/**
|
||||
* URL of the homeserver selected during login
|
||||
*/
|
||||
homeserverUrl: string;
|
||||
// identity server url as discovered during login
|
||||
/**
|
||||
* Identity server URL as discovered during login
|
||||
*/
|
||||
identityServerUrl?: string;
|
||||
// accessToken gained from OIDC token issuer
|
||||
/**
|
||||
* Access Token gained from OIDC token issuer
|
||||
*/
|
||||
accessToken: string;
|
||||
// refreshToken gained from OIDC token issuer, when falsy token cannot be refreshed
|
||||
/**
|
||||
* Refresh Token gained from OIDC token issuer, when falsy token cannot be refreshed
|
||||
*/
|
||||
refreshToken?: string;
|
||||
// idToken gained from OIDC token issuer
|
||||
/**
|
||||
* ID Token gained from OIDC token issuer
|
||||
*/
|
||||
idToken?: string;
|
||||
// this client's id as registered with the OIDC issuer
|
||||
/**
|
||||
* This client's ID as registered with the OIDC issuer
|
||||
*/
|
||||
clientId: string;
|
||||
// issuer used during authentication
|
||||
/**
|
||||
* Issuer used during authentication
|
||||
*/
|
||||
issuer: string;
|
||||
// claims of the given access token; used during token refresh to validate new tokens
|
||||
/**
|
||||
* Claims of the given access token; used during token refresh to validate new tokens
|
||||
*/
|
||||
idTokenClaims: IdTokenClaims;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to complete authorization code flow to get an access token
|
||||
* @param urlParams the parameters extracted from the app-load URI.
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import React, { type ComponentProps, createRef, type RefObject } from "react";
|
||||
import { fireEvent, render, type RenderResult, screen, waitFor, within, act } from "jest-matrix-react";
|
||||
import { type Mocked, mocked } from "jest-mock";
|
||||
import { ClientEvent, type MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
UserVerificationStatus,
|
||||
type CryptoApi,
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
import * as qrLogin from "matrix-js-sdk/src/rendezvous";
|
||||
|
||||
import MatrixChat from "../../../../src/components/structures/MatrixChat";
|
||||
import * as StorageAccess from "../../../../src/utils/StorageAccess";
|
||||
@@ -69,6 +71,8 @@ import { clearStorage } from "../../../../src/Lifecycle";
|
||||
import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts";
|
||||
import UserSettingsDialog from "../../../../src/components/views/dialogs/UserSettingsDialog.tsx";
|
||||
import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts";
|
||||
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc.ts";
|
||||
import { type QrLoginCredentials } from "../../../../src/components/views/auth/LoginWithQR.tsx";
|
||||
|
||||
jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({
|
||||
completeAuthorizationCodeGrant: jest.fn(),
|
||||
@@ -82,6 +86,31 @@ jest.mock("../../../../src/theme");
|
||||
/** The matrix versions our mock server claims to support */
|
||||
const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"];
|
||||
|
||||
function createMockCrypto(): CryptoApi {
|
||||
return {
|
||||
getVersion: jest.fn().mockReturnValue("Version 0"),
|
||||
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||
getUserDeviceInfo: jest.fn().mockReturnValue({
|
||||
get: jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
"devid",
|
||||
{ deviceId: "devid", dehydrated: false, getIdentityKey: jest.fn().mockReturnValue("k") },
|
||||
],
|
||||
]),
|
||||
),
|
||||
}),
|
||||
getUserVerificationStatus: jest.fn().mockResolvedValue(new UserVerificationStatus(true, true, false)),
|
||||
setDeviceIsolationMode: jest.fn(),
|
||||
isDehydrationSupported: jest.fn().mockReturnValue(false),
|
||||
getDeviceVerificationStatus: jest.fn().mockResolvedValue({ signedByOwner: true } as DeviceVerificationStatus),
|
||||
isCrossSigningReady: jest.fn().mockReturnValue(false),
|
||||
requestOwnUserVerification: jest.fn().mockResolvedValue({ cancel: jest.fn(), on: jest.fn() }),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("<MatrixChat />", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const deviceId = "qwertyui";
|
||||
@@ -179,8 +208,11 @@ describe("<MatrixChat />", () => {
|
||||
warning: "",
|
||||
};
|
||||
let defaultProps: ComponentProps<typeof MatrixChat>;
|
||||
const getComponent = (props: Partial<ComponentProps<typeof MatrixChat>> = {}) => {
|
||||
return render(<MatrixChat {...defaultProps} {...props} />);
|
||||
const getComponent = (
|
||||
props: Partial<ComponentProps<typeof MatrixChat>> = {},
|
||||
ref?: RefObject<MatrixChat | null>,
|
||||
) => {
|
||||
return render(<MatrixChat {...defaultProps} {...props} ref={ref} />);
|
||||
};
|
||||
|
||||
// make test results readable
|
||||
@@ -311,6 +343,118 @@ describe("<MatrixChat />", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("qr login", () => {
|
||||
beforeEach(() => {
|
||||
const authConfig = makeDelegatedAuthConfig();
|
||||
defaultProps.config.validated_server_config!.delegatedAuthentication = authConfig;
|
||||
fetchMock.post(authConfig.registration_endpoint!, { client_id: "abc123" });
|
||||
mockPlatformPeg({
|
||||
getOidcClientMetadata: jest.fn().mockReturnValue({
|
||||
clientName: "App name",
|
||||
clientUri: "https://company",
|
||||
redirectUris: ["https://app"],
|
||||
logoUri: "https://company/logo.png",
|
||||
applicationType: "web",
|
||||
}),
|
||||
});
|
||||
jest.spyOn(qrLogin, "signInByGeneratingQR").mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
it("should open QrLoginDialog on ViewQrLogin action", async () => {
|
||||
getComponent();
|
||||
defaultDispatcher.fire(Action.ViewQrLogin);
|
||||
await expect(screen.findByRole("dialog", { name: "Sign in with QR code" })).resolves.toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should ignore ViewQrLogin action when logged in", async () => {
|
||||
await populateStorageForSession();
|
||||
getComponent();
|
||||
// wait for logged in view to load
|
||||
await screen.findByLabelText("User menu");
|
||||
|
||||
defaultDispatcher.fire(Action.ViewQrLogin);
|
||||
expect(screen.queryByRole("dialog", { name: "Sign in with QR code" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fire ViewQrLogin action on 'qr_login' route", async () => {
|
||||
const ref = createRef<MatrixChat>();
|
||||
getComponent({}, ref);
|
||||
ref.current!.showScreen("qr_login");
|
||||
await expect(screen.findByRole("dialog", { name: "Sign in with QR code" })).resolves.toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle qr login completed", async () => {
|
||||
const qrCreds: QrLoginCredentials = {
|
||||
accessToken: "at",
|
||||
homeserverUrl: "https://homeserver",
|
||||
clientId: "ci",
|
||||
idToken: "it",
|
||||
issuer: defaultProps.config.validated_server_config!.delegatedAuthentication!.issuer,
|
||||
deviceId: "di",
|
||||
secrets: {
|
||||
cross_signing: {
|
||||
master_key: "mk",
|
||||
self_signing_key: "ssk",
|
||||
user_signing_key: "usk",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockClient.whoami.mockResolvedValue({ user_id: "@user:homeserver", device_id: qrCreds.deviceId });
|
||||
mockClient.getCrypto.mockReturnValue({
|
||||
...createMockCrypto(),
|
||||
crossSignDevice: jest.fn().mockResolvedValue(undefined),
|
||||
importSecretsBundle: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
getComponent();
|
||||
|
||||
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
|
||||
|
||||
// Assert welcome screen
|
||||
await screen.findByText("Welcome to Test");
|
||||
|
||||
// Open QR dialog so we can grab the onLoggedIn method
|
||||
defaultDispatcher.fire(Action.ViewQrLogin, true);
|
||||
expect(createDialogSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{
|
||||
onLoggedIn: expect.any(Function),
|
||||
serverConfig: defaultProps.config.validated_server_config,
|
||||
},
|
||||
"mx_LoginWithQR_dialog",
|
||||
false,
|
||||
true,
|
||||
);
|
||||
const { onLoggedIn } = createDialogSpy.mock.calls[0][1] as {
|
||||
onLoggedIn(creds: QrLoginCredentials): Promise<void>;
|
||||
};
|
||||
|
||||
const configureFromCompletedSpy = jest.spyOn(Lifecycle, "configureFromCompletedOAuthLogin");
|
||||
const restoreSessionSpy = jest.spyOn(Lifecycle, "restoreSessionFromStorage");
|
||||
const prom = onLoggedIn(qrCreds);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(configureFromCompletedSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: qrCreds.accessToken,
|
||||
homeserverUrl: qrCreds.homeserverUrl,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(restoreSessionSpy).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(mockClient.getCrypto()!.importSecretsBundle).toHaveBeenCalledWith(qrCreds.secrets),
|
||||
);
|
||||
|
||||
await prom;
|
||||
|
||||
// initial sync
|
||||
mockClient.emit(ClientEvent.Sync, SyncState.Prepared, null);
|
||||
// wait for logged in view to load
|
||||
await screen.findByLabelText("User menu");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when query params have a OIDC params", () => {
|
||||
const issuer = "https://auth.com/";
|
||||
const homeserverUrl = "https://matrix.org";
|
||||
@@ -1186,37 +1330,6 @@ describe("<MatrixChat />", () => {
|
||||
await screen.findByRole("heading", { name: "Confirm your digital identity", level: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
function createMockCrypto(): CryptoApi {
|
||||
return {
|
||||
getVersion: jest.fn().mockReturnValue("Version 0"),
|
||||
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
|
||||
getUserDeviceInfo: jest.fn().mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
"devid",
|
||||
{
|
||||
deviceId: "devid",
|
||||
dehydrated: false,
|
||||
getIdentityKey: jest.fn().mockReturnValue("k"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
}),
|
||||
getUserVerificationStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new UserVerificationStatus(true, true, false)),
|
||||
setDeviceIsolationMode: jest.fn(),
|
||||
isDehydrationSupported: jest.fn().mockReturnValue(false),
|
||||
getDeviceVerificationStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ signedByOwner: true } as DeviceVerificationStatus),
|
||||
isCrossSigningReady: jest.fn().mockReturnValue(false),
|
||||
requestOwnUserVerification: jest.fn().mockResolvedValue({ cancel: jest.fn(), on: jest.fn() }),
|
||||
} as any;
|
||||
}
|
||||
});
|
||||
|
||||
describe("showScreen", () => {
|
||||
|
||||
+108
@@ -269,6 +269,114 @@ exports[`<MatrixChat /> Multi-tab lockout waits for other tab to stop during sta
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MatrixChat /> qr login should fire ViewQrLogin action on 'qr_login' route 1`] = `
|
||||
<div
|
||||
aria-label="Sign in with QR code"
|
||||
class=""
|
||||
data-focus-lock-disabled="false"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Dialog_header"
|
||||
/>
|
||||
<div
|
||||
class="mx_LoginWithQR"
|
||||
data-testid="login-with-qr"
|
||||
>
|
||||
<div
|
||||
class="mx_LoginWithQR_main"
|
||||
>
|
||||
<div
|
||||
class="mx_LoginWithQR_spinner"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<svg
|
||||
aria-label="Loading…"
|
||||
class="_icon_1855a_18"
|
||||
data-testid="spinner"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_LoginWithQR_buttons"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MatrixChat /> qr login should open QrLoginDialog on ViewQrLogin action 1`] = `
|
||||
<div
|
||||
aria-label="Sign in with QR code"
|
||||
class=""
|
||||
data-focus-lock-disabled="false"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Dialog_header"
|
||||
/>
|
||||
<div
|
||||
class="mx_LoginWithQR"
|
||||
data-testid="login-with-qr"
|
||||
>
|
||||
<div
|
||||
class="mx_LoginWithQR_main"
|
||||
>
|
||||
<div
|
||||
class="mx_LoginWithQR_spinner"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<svg
|
||||
aria-label="Loading…"
|
||||
class="_icon_1855a_18"
|
||||
data-testid="spinner"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_LoginWithQR_buttons"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MatrixChat /> should render spinner while app is loading 1`] = `
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -28,6 +28,7 @@ import { UIFeature } from "../../../../../src/settings/UIFeature";
|
||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
import { type FeatureSettingKey } from "../../../../../src/settings/Settings.tsx";
|
||||
import { mockOpenIdConfiguration } from "../../../../test-utils/oidc.ts";
|
||||
|
||||
mockPlatformPeg({
|
||||
supportsSpellCheckSettings: jest.fn().mockReturnValue(false),
|
||||
@@ -48,6 +49,7 @@ jest.mock("../../../../../src/settings/SettingsStore", () => ({
|
||||
shouldHaveWarning: jest.fn(),
|
||||
disabledMessage: jest.fn(),
|
||||
settingIsOveriddenAtConfigLevel: jest.fn(),
|
||||
doesSettingSupportLevel: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<UserSettingsDialog />", () => {
|
||||
@@ -71,6 +73,8 @@ describe("<UserSettingsDialog />", () => {
|
||||
getIgnoredUsers: jest.fn().mockResolvedValue([]),
|
||||
getPushers: jest.fn().mockResolvedValue([]),
|
||||
getProfileInfo: jest.fn().mockResolvedValue({}),
|
||||
getMediaConfig: jest.fn(),
|
||||
getAuthMetadata: jest.fn().mockResolvedValue(mockOpenIdConfiguration()),
|
||||
});
|
||||
sdkContext = new SdkContextClass();
|
||||
sdkContext.client = mockClient;
|
||||
|
||||
@@ -14,11 +14,22 @@ import {
|
||||
MSC4108FailureReason,
|
||||
MSC4108SignInWithQR,
|
||||
RendezvousError,
|
||||
RendezvousIntent,
|
||||
} from "matrix-js-sdk/src/rendezvous";
|
||||
import { HTTPError, type MatrixClient, MatrixHttpApi } from "matrix-js-sdk/src/matrix";
|
||||
import { mockOpenIdConfiguration } from "matrix-js-sdk/src/testing";
|
||||
import {
|
||||
AutoDiscovery,
|
||||
AutoDiscoveryAction,
|
||||
HTTPError,
|
||||
type MatrixClient,
|
||||
MatrixHttpApi,
|
||||
OAuthGrantType,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
|
||||
import LoginWithQR, { LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
|
||||
import { Click, Mode, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
|
||||
import { mockPlatformPeg } from "../../../../../test-utils";
|
||||
|
||||
jest.mock("matrix-js-sdk/src/rendezvous/transports");
|
||||
jest.mock("matrix-js-sdk/src/rendezvous/channels");
|
||||
@@ -50,6 +61,7 @@ function makeClient() {
|
||||
getClientWellKnown: jest.fn().mockReturnValue({}),
|
||||
getCrypto: jest.fn().mockReturnValue({}),
|
||||
getDomain: jest.fn(),
|
||||
getAuthMetadata: jest.fn().mockReturnValue(mockOpenIdConfiguration()),
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
cli.http = new MatrixHttpApi(cli, {
|
||||
@@ -80,209 +92,313 @@ describe("<LoginWithQR />", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client = makeClient();
|
||||
jest.clearAllMocks();
|
||||
jest.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("MSC4108", () => {
|
||||
const getComponent = (props: {
|
||||
client: MatrixClient;
|
||||
onFinished?: () => void;
|
||||
ref?: RefObject<LoginWithQR | null>;
|
||||
}) => <LoginWithQR {...defaultProps} {...props} />;
|
||||
|
||||
test("render QR then back", async () => {
|
||||
const onFinished = jest.fn();
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "generateCode");
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols");
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, onFinished, ref }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.ShowingQR,
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
describe("reciprocate", () => {
|
||||
const getComponent = (props: {
|
||||
client: MatrixClient;
|
||||
onFinished?: () => void;
|
||||
ref?: RefObject<LoginWithQR | null>;
|
||||
}) => (
|
||||
<LoginWithQR
|
||||
{...defaultProps}
|
||||
{...props}
|
||||
intent={RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE}
|
||||
/>
|
||||
);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||
test("render QR then back", async () => {
|
||||
const onFinished = jest.fn();
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "generateCode");
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols");
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, onFinished, ref }));
|
||||
|
||||
// back
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Back);
|
||||
expect(onFinished).toHaveBeenCalledWith(false);
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||
});
|
||||
|
||||
test("should open a new channel if expires before qr scan", async () => {
|
||||
const onFinished = jest.fn();
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, onFinished, ref }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.ShowingQR,
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||
|
||||
// Expire the channel
|
||||
rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
|
||||
await jest.runAllTimersAsync();
|
||||
await waitFor(() => expect(ref.current!.state.rendezvous).toBeDefined());
|
||||
expect(ref.current!.state.rendezvous).not.toBe(rendezvous);
|
||||
});
|
||||
|
||||
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("should show error if check code doesn't match", 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, "12");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
failureReason: LoginWithQRFailureReason.CheckCodeMismatch,
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("reciprocates login", async () => {
|
||||
const ref = createRef<LoginWithQR>();
|
||||
jest.spyOn(global.window, "open");
|
||||
|
||||
render(getComponent({ client, ref }));
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({});
|
||||
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");
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.shareSecrets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles errors during protocol negotiation", async () => {
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, ref }));
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
|
||||
const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol);
|
||||
// @ts-ignore work-around for lazy mocks
|
||||
err.code = MSC4108FailureReason.UnsupportedProtocol;
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockRejectedValue(err);
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.ShowingQR,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
|
||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||
|
||||
// back (cancel)
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Cancel);
|
||||
expect(onFinished).toHaveBeenCalledWith(false);
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||
});
|
||||
|
||||
test("should open a new channel if expires before qr scan", async () => {
|
||||
const onFinished = jest.fn();
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, onFinished, ref }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.ShowingQR,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||
|
||||
// Expire the channel
|
||||
rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
|
||||
await jest.runAllTimersAsync();
|
||||
await waitFor(() => expect(ref.current!.state.rendezvous).toBeDefined());
|
||||
expect(ref.current!.state.rendezvous).not.toBe(rendezvous);
|
||||
});
|
||||
|
||||
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("should show error if check code doesn't match", 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),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Approve, "12");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
failureReason: LoginWithQRFailureReason.CheckCodeMismatch,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("reciprocates login", async () => {
|
||||
const ref = createRef<LoginWithQR>();
|
||||
jest.spyOn(global.window, "open");
|
||||
|
||||
render(getComponent({ client, ref }));
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({});
|
||||
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),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Approve);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.WaitingForDevice,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank");
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.shareSecrets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles errors during protocol negotiation", async () => {
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, ref }));
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
|
||||
const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol);
|
||||
// @ts-ignore work-around for lazy mocks
|
||||
err.code = MSC4108FailureReason.UnsupportedProtocol;
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockRejectedValue(err);
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
phase: Phase.ShowingQR,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, ref }));
|
||||
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),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Cancel);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
}),
|
||||
describe("login", () => {
|
||||
const getComponent = (props: {
|
||||
client: MatrixClient;
|
||||
onFinished?: () => void;
|
||||
onLoggedIn?: () => Promise<void>;
|
||||
ref?: RefObject<LoginWithQR | null>;
|
||||
}) => (
|
||||
<LoginWithQR
|
||||
onLoggedIn={jest.fn()}
|
||||
{...defaultProps}
|
||||
{...props}
|
||||
intent={RendezvousIntent.LOGIN_ON_NEW_DEVICE}
|
||||
/>
|
||||
);
|
||||
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockRejectedValue(
|
||||
new HTTPError("Internal Server Error", 500),
|
||||
);
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Approve);
|
||||
test("should handle qr login", async () => {
|
||||
fetchMock.get("https://hs/_matrix/client/versions", {
|
||||
unstable_features: {},
|
||||
versions: ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"],
|
||||
});
|
||||
const authMetadata = {
|
||||
...mockOpenIdConfiguration("https://auth.org/", [OAuthGrantType.DeviceAuthorization]),
|
||||
jwks_uri: undefined,
|
||||
};
|
||||
fetchMock.get("https://hs/_matrix/client/unstable/org.matrix.msc2965/auth_metadata", authMetadata);
|
||||
fetchMock.post(authMetadata.registration_endpoint!, {
|
||||
client_id: "!client_id!",
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
phase: Phase.Error,
|
||||
failureReason: ClientRendezvousFailureReason.Unknown,
|
||||
mockPlatformPeg({
|
||||
getOidcClientMetadata: jest.fn().mockReturnValue({
|
||||
clientName: "App name",
|
||||
clientUri: "https://company",
|
||||
redirectUris: ["https://app"],
|
||||
logoUri: "https://company/logo.png",
|
||||
applicationType: "web",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles user cancelling during reciprocation", async () => {
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, ref }));
|
||||
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),
|
||||
}),
|
||||
);
|
||||
const ref = createRef<LoginWithQR>();
|
||||
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Cancel);
|
||||
render(getComponent({ client, ref }));
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({
|
||||
secrets: {
|
||||
cross_signing: {
|
||||
master_key: "mk",
|
||||
self_signing_key: "ssk",
|
||||
user_signing_key: "usk",
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({ serverName: "hs" });
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
|
||||
userCode: "123456",
|
||||
});
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "completeLoginOnNewDevice").mockResolvedValue({
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
jest.spyOn(AutoDiscovery, "findClientConfig").mockResolvedValue({
|
||||
"m.homeserver": { base_url: "https://hs", state: AutoDiscoveryAction.SUCCESS },
|
||||
"m.identity_server": { state: AutoDiscoveryAction.PROMPT },
|
||||
});
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Approve);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.WaitingForDevice,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE,
|
||||
userCode: "123456",
|
||||
}),
|
||||
);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.shareSecrets).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+90
-56
@@ -8,11 +8,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import { ClientRendezvousFailureReason, MSC4108FailureReason } from "matrix-js-sdk/src/rendezvous";
|
||||
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
|
||||
|
||||
import { mockQRCodeRender, resetQRCodeMock, waitForQRCodeRender } from "../../../../../test-utils/qrcode";
|
||||
import LoginWithQRFlow from "../../../../../../src/components/views/auth/LoginWithQRFlow";
|
||||
import { LoginWithQRFailureReason, type FailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
|
||||
import { type FailureReason, LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
|
||||
import { Click, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
|
||||
|
||||
describe("<LoginWithQRFlow />", () => {
|
||||
@@ -27,72 +27,106 @@ describe("<LoginWithQRFlow />", () => {
|
||||
onClick?: () => Promise<void>;
|
||||
failureReason?: FailureReason;
|
||||
code?: Uint8Array;
|
||||
intent: RendezvousIntent;
|
||||
}) => <LoginWithQRFlow {...defaultProps} {...props} />;
|
||||
|
||||
beforeEach(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
resetQRCodeMock();
|
||||
onClick.mockReset();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders spinner while loading", async () => {
|
||||
const { container } = render(getComponent({ phase: Phase.Loading }));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders spinner whilst QR generating", async () => {
|
||||
const { container } = render(getComponent({ phase: Phase.ShowingQR }));
|
||||
expect(screen.getAllByTestId("spinner")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders QR code", async () => {
|
||||
mockQRCodeRender();
|
||||
const { container } = render(
|
||||
getComponent({ phase: Phase.ShowingQR, code: new TextEncoder().encode("mock-code") }),
|
||||
);
|
||||
// QR code is rendered async so we wait for it:
|
||||
await waitForQRCodeRender();
|
||||
expect(screen.getAllByAltText("QR Code")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders spinner while signing in", async () => {
|
||||
const { container } = render(getComponent({ phase: Phase.WaitingForDevice }));
|
||||
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
fireEvent.click(screen.getByTestId("cancel-button"));
|
||||
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
|
||||
});
|
||||
|
||||
it("renders spinner while verifying", async () => {
|
||||
const { container } = render(getComponent({ phase: Phase.Verifying }));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders check code confirmation", async () => {
|
||||
const { container } = render(getComponent({ phase: Phase.OutOfBandConfirmation }));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("errors", () => {
|
||||
for (const failureReason of [
|
||||
...Object.values(MSC4108FailureReason),
|
||||
...Object.values(LoginWithQRFailureReason),
|
||||
...Object.values(ClientRendezvousFailureReason),
|
||||
]) {
|
||||
it(`renders ${failureReason}`, async () => {
|
||||
describe.each([RendezvousIntent.LOGIN_ON_NEW_DEVICE, RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE])(
|
||||
"%s",
|
||||
(intent) => {
|
||||
it("renders spinner while loading", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.Error,
|
||||
failureReason,
|
||||
phase: Phase.Loading,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("renders spinner whilst QR generating", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.ShowingQR,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByTestId("spinner")).toBeVisible();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders QR code", async () => {
|
||||
mockQRCodeRender();
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.ShowingQR,
|
||||
code: new TextEncoder().encode("mock-code"),
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
// QR code is rendered async so we wait for it:
|
||||
await waitForQRCodeRender();
|
||||
expect(screen.getAllByAltText("QR Code")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders spinner while signing in", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.WaitingForDevice,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
fireEvent.click(screen.getByTestId("cancel-button"));
|
||||
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
|
||||
});
|
||||
|
||||
it("renders spinner while verifying", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.Verifying,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders check code confirmation", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("errors", () => {
|
||||
for (const failureReason of [
|
||||
...Object.values(MSC4108FailureReason),
|
||||
...Object.values(LoginWithQRFailureReason),
|
||||
...Object.values(ClientRendezvousFailureReason),
|
||||
]) {
|
||||
it(`renders ${failureReason}`, async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.Error,
|
||||
failureReason,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+1718
-101
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user