From 486fa57b686b8443636df838ac60420e4cc64c00 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 4 Jun 2026 14:50:17 +0100 Subject: [PATCH] [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 Co-authored-by: David Langley --- apps/web/res/css/_common.pcss | 19 +- apps/web/res/css/views/auth/_LoginWithQR.pcss | 63 +- apps/web/src/Lifecycle.ts | 58 +- .../views/dialogs/QrLoginDialog.tsx | 65 + .../components/structures/ErrorMessage.tsx | 4 +- .../src/components/structures/MatrixChat.tsx | 56 +- .../components/views/auth/DefaultWelcome.tsx | 74 +- .../views/auth/LoginWithQR-types.ts | 16 +- .../src/components/views/auth/LoginWithQR.tsx | 248 ++- .../components/views/auth/LoginWithQRFlow.tsx | 169 +- .../web/src/components/views/auth/Welcome.tsx | 10 +- .../views/elements/SettingsFlag.tsx | 5 +- .../settings/tabs/user/SessionManagerTab.tsx | 8 +- apps/web/src/dispatcher/actions.ts | 5 + apps/web/src/i18n/strings/en_EN.json | 20 +- apps/web/src/settings/Settings.tsx | 9 + apps/web/src/utils/oidc/authorize.ts | 39 +- .../components/structures/MatrixChat-test.tsx | 181 +- .../__snapshots__/MatrixChat-test.tsx.snap | 108 + .../views/dialogs/UserSettingsDialog-test.tsx | 4 + .../settings/devices/LoginWithQR-test.tsx | 478 +++-- .../settings/devices/LoginWithQRFlow-test.tsx | 146 +- .../LoginWithQRFlow-test.tsx.snap | 1819 ++++++++++++++++- docs/labs.md | 7 + 24 files changed, 3041 insertions(+), 570 deletions(-) create mode 100644 apps/web/src/async-components/views/dialogs/QrLoginDialog.tsx diff --git a/apps/web/res/css/_common.pcss b/apps/web/res/css/_common.pcss index 63a9c98c7e..fe6f775f1e 100644 --- a/apps/web/res/css/_common.pcss +++ b/apps/web/res/css/_common.pcss @@ -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, diff --git a/apps/web/res/css/views/auth/_LoginWithQR.pcss b/apps/web/res/css/views/auth/_LoginWithQR.pcss index 82703e08e2..9efaf61e80 100644 --- a/apps/web/res/css/views/auth/_LoginWithQR.pcss +++ b/apps/web/res/css/views/auth/_LoginWithQR.pcss @@ -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; diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index 7ae860bf2a..edee4e73c5 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -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): Promise { + 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 diff --git a/apps/web/src/async-components/views/dialogs/QrLoginDialog.tsx b/apps/web/src/async-components/views/dialogs/QrLoginDialog.tsx new file mode 100644 index 0000000000..aeb8c06dd3 --- /dev/null +++ b/apps/web/src/async-components/views/dialogs/QrLoginDialog.tsx @@ -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; +} + +/** + * Dialog for facilitating the Login with QR flow, shown from DefaultWelcome. + */ +const QrLoginDialog: FC = ({ serverConfig, onLoggedIn, onFinished }) => { + const tempClient = useMemo(() => createClient({ baseUrl: serverConfig.hsUrl }), [serverConfig]); + + const [phase, setPhase] = useState(); + + const hasCancel = phase === Phase.ShowingQR; + + return ( + + + + ); +}; + +export default QrLoginDialog; diff --git a/apps/web/src/components/structures/ErrorMessage.tsx b/apps/web/src/components/structures/ErrorMessage.tsx index c721e67fc5..cd30d19361 100644 --- a/apps/web/src/components/structures/ErrorMessage.tsx +++ b/apps/web/src/components/structures/ErrorMessage.tsx @@ -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 = ({ message }) => { - const icon = message ? : null; + const icon = message ? : null; return (
diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 0dbc2af2e4..bea3d2d453 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -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 { 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 { 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 { 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 => { + // 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 => { const forceVerify = await this.shouldForceVerification(); @@ -2220,7 +2274,7 @@ export default class MatrixChat extends React.PureComponent { ); } } else if (this.state.view === Views.WELCOME) { - view = ; + view = ; } else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) { const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail; view = ( diff --git a/apps/web/src/components/views/auth/DefaultWelcome.tsx b/apps/web/src/components/views/auth/DefaultWelcome.tsx index 348b9c0a4f..b832a2f957 100644 --- a/apps/web/src/components/views/auth/DefaultWelcome.tsx +++ b/apps/web/src/components/views/auth/DefaultWelcome.tsx @@ -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 = ({ 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 = ; + } else { + body = ( +
+ {showQrButton && ( + + )} + + + {showGuestFunctions && ( + + )} +
+ ); + } + return ( ); }; diff --git a/apps/web/src/components/views/auth/LoginWithQR-types.ts b/apps/web/src/components/views/auth/LoginWithQR-types.ts index 8e1d58515d..cb6ef911cf 100644 --- a/apps/web/src/components/views/auth/LoginWithQR-types.ts +++ b/apps/web/src/components/views/auth/LoginWithQR-types.ts @@ -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, } diff --git a/apps/web/src/components/views/auth/LoginWithQR.tsx b/apps/web/src/components/views/auth/LoginWithQR.tsx index 808e42e66b..0c104c6deb 100644 --- a/apps/web/src/components/views/auth/LoginWithQR.tsx +++ b/apps/web/src/components/views/auth/LoginWithQR.tsx @@ -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 & + Awaited> & { + 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; + }, + { + /** + * 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>, "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 { +export default class LoginWithQR extends React.Component { 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): void { + public componentDidUpdate(prevProps: Readonly, prevState: Readonly): 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 { private onFinished(success: boolean): void { this.finished = true; + if (!success) { + this.abortController?.abort(); + } this.props.onFinished(success); } private generateAndShowCode = async (abortController: AbortController): Promise => { 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 { } 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 { 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 { } 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 { // 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 { private onFailure = async (reason: RendezvousFailureReason): Promise => { 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 { 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 { verificationUri: undefined, failureReason: undefined, userCode: undefined, - checkCode: undefined, }); } @@ -215,19 +407,17 @@ export default class LoginWithQR extends React.Component { 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 { 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} /> ); } diff --git a/apps/web/src/components/views/auth/LoginWithQRFlow.tsx b/apps/web/src/components/views/auth/LoginWithQRFlow.tsx index 34a3b22db4..6b5a213ca8 100644 --- a/apps/web/src/components/views/auth/LoginWithQRFlow.tsx +++ b/apps/web/src/components/views/auth/LoginWithQRFlow.tsx @@ -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; + /** + * 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 { private checkCodeInput = createRef(); @@ -49,9 +69,9 @@ export default class LoginWithQRFlow extends React.Component { }; private cancelButton = (): JSX.Element => ( - + ); private simpleSpinner = (description?: string): JSX.Element => { @@ -74,9 +94,16 @@ export default class LoginWithQRFlow extends React.Component { switch (this.props.phase) { case Phase.Error: { backButton = false; + buttons = ( + + ); let Icon = ErrorIcon; - let success: boolean | null = false; + let iconKind: ComponentProps["kind"] = "critical"; let title: string | undefined; let message: ReactNode | undefined; @@ -89,6 +116,9 @@ export default class LoginWithQRFlow extends React.Component { 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 { 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 { 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 { 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 { className = "mx_LoginWithQR_error"; main = ( <> -
- -
+ + + {title} @@ -178,6 +208,11 @@ export default class LoginWithQRFlow extends React.Component { backButton = false; main = ( <> + {this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE && ( + + + + )} {_t("auth|qr_code_login|check_code_heading")} @@ -209,39 +244,62 @@ export default class LoginWithQRFlow extends React.Component { buttons = ( <> - {_t("action|continue")} - - + ); 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: {_t("auth|qr_code_login|scan_qr_code")}, - }), - _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: {_t("settings|sessions|sign_in_with_qr")}, + }), + _t("auth|qr_code_login|choose_desktop_computer", { + desktopComputer: {_t("auth|qr_code_login|desktop_computer")}, + }), + _t("auth|qr_code_login|select_ready_to_scan", { + readyToScan: {_t("auth|qr_code_login|ready_to_scan")}, + }), + _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: {_t("auth|qr_code_login|scan_qr_code")}, + }), + _t("auth|qr_code_login|point_the_camera"), + _t("auth|qr_code_login|follow_remaining_instructions"), + ]; + } main = ( <> - {_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")}
{this.props.code ? ( @@ -252,7 +310,7 @@ export default class LoginWithQRFlow extends React.Component {
    {steps.map((step, i) => ( -
  1. {step}
  2. +
  3. {step}
  4. ))}
@@ -263,18 +321,27 @@ export default class LoginWithQRFlow extends React.Component { main = this.simpleSpinner(); break; case Phase.WaitingForDevice: - main = ( - <> - {this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))} - {this.props.userCode ? ( -
-

{_t("auth|qr_code_login|security_code")}

-

{_t("auth|qr_code_login|security_code_prompt")}

-

{this.props.userCode}

-
- ) : null} - - ); + main = + this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE ? ( + <> + + + + + {_t("auth|qr_code_login|security_code_title")} + + {_t("auth|qr_code_login|security_code_prompt")} + + {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 { return (
- {backButton ? ( + {this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE && backButton ? (
diff --git a/apps/web/src/components/views/auth/Welcome.tsx b/apps/web/src/components/views/auth/Welcome.tsx index f8c00b2127..fc2c63c436 100644 --- a/apps/web/src/components/views/auth/Welcome.tsx +++ b/apps/web/src/components/views/auth/Welcome.tsx @@ -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 { +interface Props { + serverConfig: ValidatedServerConfig; +} + +export default class Welcome extends React.PureComponent { 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 { if (pageUrl) { body = ; } else { - body = ; + body = ; } return ( diff --git a/apps/web/src/components/views/elements/SettingsFlag.tsx b/apps/web/src/components/views/elements/SettingsFlag.tsx index 3e09b298ce..66a6b6211e 100644 --- a/apps/web/src/components/views/elements/SettingsFlag.tsx +++ b/apps/web/src/components/views/elements/SettingsFlag.tsx @@ -59,9 +59,10 @@ export default class SettingsFlag extends React.Component { } 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); diff --git a/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 01aba5ccdb..6f7db4227e 100644 --- a/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/apps/web/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -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 ( }> - + ); } diff --git a/apps/web/src/dispatcher/actions.ts b/apps/web/src/dispatcher/actions.ts index b8a4ebdf88..ec61e65391 100644 --- a/apps/web/src/dispatcher/actions.ts +++ b/apps/web/src/dispatcher/actions.ts @@ -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. */ diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 2cb5bdab63..17faf2caeb 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -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? Sign in here", + "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 .", "bridge_state_workspace": "Workspace: ", "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", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 4ca3bf731e..a04da85104 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -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; @@ -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` diff --git a/apps/web/src/utils/oidc/authorize.ts b/apps/web/src/utils/oidc/authorize.ts index 18bd910fc1..da94d1a2ec 100644 --- a/apps/web/src/utils/oidc/authorize.ts +++ b/apps/web/src/utils/oidc/authorize.ts @@ -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. diff --git a/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx b/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx index 9d4084d220..44085f8646 100644 --- a/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -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("", () => { const userId = "@alice:server.org"; const deviceId = "qwertyui"; @@ -179,8 +208,11 @@ describe("", () => { warning: "", }; let defaultProps: ComponentProps; - const getComponent = (props: Partial> = {}) => { - return render(); + const getComponent = ( + props: Partial> = {}, + ref?: RefObject, + ) => { + return render(); }; // make test results readable @@ -311,6 +343,118 @@ describe("", () => { ); }); + 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(); + 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; + }; + + 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("", () => { 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", () => { diff --git a/apps/web/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap b/apps/web/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap index 0cdb605a9c..2e971f1f5a 100644 --- a/apps/web/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap +++ b/apps/web/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap @@ -269,6 +269,114 @@ exports[` Multi-tab lockout waits for other tab to stop during sta
`; +exports[` qr login should fire ViewQrLogin action on 'qr_login' route 1`] = ` +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+`; + +exports[` qr login should open QrLoginDialog on ViewQrLogin action 1`] = ` +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+`; + exports[` should render spinner while app is loading 1`] = `
({ shouldHaveWarning: jest.fn(), disabledMessage: jest.fn(), settingIsOveriddenAtConfigLevel: jest.fn(), + doesSettingSupportLevel: jest.fn(), })); describe("", () => { @@ -71,6 +73,8 @@ describe("", () => { 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; diff --git a/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx b/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx index f6c77e4029..c2d49af30c 100644 --- a/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx +++ b/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx @@ -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("", () => { }); afterEach(() => { - client = makeClient(); jest.clearAllMocks(); jest.useRealTimers(); cleanup(); }); describe("MSC4108", () => { - const getComponent = (props: { - client: MatrixClient; - onFinished?: () => void; - ref?: RefObject; - }) => ; - - 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(); - 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; + }) => ( + ); - 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(); + 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(); - 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(); - 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(); - 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(); + 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(); + 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(); + 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(); + 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; + ref?: RefObject; + }) => ( + ); - 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(); - 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(); - 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(); + }); }); }); }); diff --git a/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQRFlow-test.tsx b/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQRFlow-test.tsx index 99cfa25924..b1415a89f7 100644 --- a/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQRFlow-test.tsx +++ b/apps/web/test/unit-tests/components/views/settings/devices/LoginWithQRFlow-test.tsx @@ -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("", () => { @@ -27,72 +27,106 @@ describe("", () => { onClick?: () => Promise; failureReason?: FailureReason; code?: Uint8Array; + intent: RendezvousIntent; }) => ; - 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(); + }); + } + }); + }, + ); }); diff --git a/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap index 63eb09fca7..5a2d4bc705 100644 --- a/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[` errors renders authorization_expired 1`] = ` +exports[` login.reciprocate errors renders authorization_expired 1`] = `
errors renders authorization_expired 1`] = ` class="mx_LoginWithQR_main" >
errors renders authorization_expired 1`] = `
+ > + +
`; -exports[` errors renders check_code_mismatch 1`] = ` +exports[` login.reciprocate errors renders check_code_mismatch 1`] = `
errors renders check_code_mismatch 1`] = ` class="mx_LoginWithQR_main" >
errors renders check_code_mismatch 1`] = `
+ > + +
`; -exports[` errors renders device_already_exists 1`] = ` +exports[` login.reciprocate errors renders device_already_exists 1`] = `
errors renders device_already_exists 1`] = ` class="mx_LoginWithQR_main" >
errors renders device_already_exists 1`] = `
+ > + +
`; -exports[` errors renders device_not_found 1`] = ` +exports[` login.reciprocate errors renders device_not_found 1`] = `
errors renders device_not_found 1`] = ` class="mx_LoginWithQR_main" >
errors renders device_not_found 1`] = `
+ > + +
`; -exports[` errors renders etag_missing 1`] = ` +exports[` login.reciprocate errors renders etag_missing 1`] = `
errors renders etag_missing 1`] = ` class="mx_LoginWithQR_main" >
errors renders etag_missing 1`] = `
+ > + +
`; -exports[` errors renders expired 1`] = ` +exports[` login.reciprocate errors renders expired 1`] = `
errors renders expired 1`] = ` class="mx_LoginWithQR_main" >
errors renders expired 1`] = `
+ > + +
`; -exports[` errors renders homeserver_lacks_support 1`] = ` +exports[` login.reciprocate errors renders homeserver_lacks_support 1`] = `
errors renders homeserver_lacks_support 1`] = ` class="mx_LoginWithQR_main" >
errors renders homeserver_lacks_support 1`] = `
+ > + +
`; -exports[` errors renders insecure_channel_detected 1`] = ` +exports[` login.reciprocate errors renders insecure_channel_detected 1`] = `
errors renders insecure_channel_detected 1`] = ` class="mx_LoginWithQR_main" >
errors renders insecure_channel_detected 1`] = `
+ > + +
`; -exports[` errors renders invalid_code 1`] = ` +exports[` login.reciprocate errors renders invalid_code 1`] = `
errors renders invalid_code 1`] = ` class="mx_LoginWithQR_main" >
errors renders invalid_code 1`] = `
+ > + +
`; -exports[` errors renders other_device_already_signed_in 1`] = ` +exports[` login.reciprocate errors renders other_device_already_signed_in 1`] = `
errors renders other_device_already_signed_in 1`] = class="mx_LoginWithQR_main" >
errors renders other_device_already_signed_in 1`] =
+ > + +
`; -exports[` errors renders other_device_not_signed_in 1`] = ` +exports[` login.reciprocate errors renders other_device_not_signed_in 1`] = `
errors renders other_device_not_signed_in 1`] = ` class="mx_LoginWithQR_main" >
errors renders other_device_not_signed_in 1`] = `
+ > + +
`; -exports[` errors renders rate_limited 1`] = ` +exports[` login.reciprocate errors renders rate_limited 1`] = `
errors renders rate_limited 1`] = ` class="mx_LoginWithQR_main" >
errors renders rate_limited 1`] = `
+ > + +
`; -exports[` errors renders unexpected_message_received 1`] = ` +exports[` login.reciprocate errors renders unexpected_message_received 1`] = `
errors renders unexpected_message_received 1`] = ` class="mx_LoginWithQR_main" >
errors renders unexpected_message_received 1`] = `
+ > + +
`; -exports[` errors renders unknown 1`] = ` +exports[` login.reciprocate errors renders unknown 1`] = `
errors renders unknown 1`] = ` class="mx_LoginWithQR_main" >
errors renders unknown 1`] = `
+ > + +
`; -exports[` errors renders unsupported_protocol 1`] = ` +exports[` login.reciprocate errors renders unsupported_protocol 1`] = `
errors renders unsupported_protocol 1`] = ` class="mx_LoginWithQR_main" >
errors renders unsupported_protocol 1`] = `
+ > + +
`; -exports[` errors renders user_cancelled 1`] = ` +exports[` login.reciprocate errors renders user_cancelled 1`] = `
errors renders user_cancelled 1`] = ` class="mx_LoginWithQR_main" >
errors renders user_cancelled 1`] = `
+ > + +
`; -exports[` errors renders user_declined 1`] = ` +exports[` login.reciprocate errors renders user_declined 1`] = `
errors renders user_declined 1`] = ` class="mx_LoginWithQR_main" >
errors renders user_declined 1`] = `
+ > + +
`; -exports[` renders QR code 1`] = ` +exports[` login.reciprocate renders QR code 1`] = `
renders QR code 1`] = `
`; -exports[` renders check code confirmation 1`] = ` +exports[` login.reciprocate renders check code confirmation 1`] = `
renders check code confirmation 1`] = `
-
Continue -
-
+
+
`; -exports[` renders spinner while loading 1`] = ` +exports[` login.reciprocate renders spinner while loading 1`] = `
renders spinner while loading 1`] = `
`; -exports[` renders spinner while signing in 1`] = ` +exports[` login.reciprocate renders spinner while signing in 1`] = `
renders spinner while signing in 1`] = `

- Waiting for device to sign in + Waiting for your other device

@@ -1073,20 +1281,22 @@ exports[` renders spinner while signing in 1`] = `
-
Cancel -
+
`; -exports[` renders spinner while verifying 1`] = ` +exports[` login.reciprocate renders spinner while verifying 1`] = `
renders spinner while verifying 1`] = `
`; -exports[` renders spinner whilst QR generating 1`] = ` +exports[` login.reciprocate renders spinner whilst QR generating 1`] = `
renders spinner whilst QR generating 1`] = `
`; + +exports[` login.start errors renders authorization_expired 1`] = ` +
+
+
+
+ + + +
+

+ The sign in was not completed in time +

+

+ Sign in expired. Please try again. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders check_code_mismatch 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders device_already_exists 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders device_not_found 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders etag_missing 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ An unexpected error occurred. This may be due to a browser extension, proxy server, or server misconfiguration. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders expired 1`] = ` +
+
+
+
+ + + +
+

+ The sign in was not completed in time +

+

+ Sign in expired. Please try again. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders homeserver_lacks_support 1`] = ` +
+
+
+
+ + + + + +
+

+ QR code not supported +

+

+ Your account provider doesn't support signing into a new device with a QR code. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders insecure_channel_detected 1`] = ` +
+
+
+
+ + + +
+

+ Connection not secure +

+ A secure connection could not be made to the new device. Your existing devices are still safe and you don't need to worry about them. +

+ Now what? +

+
    +
  1. + Try signing in to the other device again with a QR code in case this was a network problem +
  2. +
  3. + If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi +
  4. +
  5. + If that doesn't work, sign in manually +
  6. +
+
+
+ +
+
+
+`; + +exports[` login.start errors renders invalid_code 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders other_device_already_signed_in 1`] = ` +
+
+
+
+ + + +
+

+ Your other device is already signed in +

+

+ You don’t need to do anything else. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders other_device_not_signed_in 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders rate_limited 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ Too many attempts in a short time. Wait some time before trying again. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders unexpected_message_received 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders unknown 1`] = ` +
+
+
+
+ + + +
+

+ Something went wrong! +

+

+ An unexpected error occurred. The request to connect your other device has been cancelled. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders unsupported_protocol 1`] = ` +
+
+
+
+ + + +
+

+ Other device not compatible +

+

+ This device does not support signing in to the other device with a QR code. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders user_cancelled 1`] = ` +
+
+
+
+ + + +
+

+ Sign in request cancelled +

+

+ The sign in was cancelled on the other device. +

+
+
+ +
+
+
+`; + +exports[` login.start errors renders user_declined 1`] = ` +
+
+
+
+ + + +
+

+ Sign in declined +

+

+ You or the account provider declined the sign in request. +

+
+
+ +
+
+
+`; + +exports[` login.start renders QR code 1`] = ` +
+
+
+

+ Scan the QR code +

+
+
+ QR Code +
+
+
    +
  1. + Open Element on your mobile device +
  2. +
  3. + + Tap your avatar and select " + + Link new device + + " + +
  4. +
  5. + + Choose " + + Desktop computer + + " + +
  6. +
  7. + + Select " + + Ready to scan + + " and scan the QR code shown here + +
  8. +
  9. + Follow the remaining instructions +
  10. +
+
+
+
+
+`; + +exports[` login.start renders check code confirmation 1`] = ` +
+
+
+
+ + + +
+

+ Enter the number shown on your other device +

+

+ This will verify that the connection to your other device is secure. +

+ +
+ + +
+`; + +exports[` login.start renders spinner while loading 1`] = ` +
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+`; + +exports[` login.start renders spinner while signing in 1`] = ` +
+
+
+
+ + + +
+

+ Your verification code +

+

+ Your account provider may ask for the following code to verify the sign in. +

+
+ +