diff --git a/.eslintrc.js b/.eslintrc.js index caeeca403d2..4bec4e83203 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -98,8 +98,6 @@ module.exports = { "!matrix-js-sdk/src/secret-storage", "!matrix-js-sdk/src/room-hierarchy", "!matrix-js-sdk/src/rendezvous", - "!matrix-js-sdk/src/rendezvous/transports", - "!matrix-js-sdk/src/rendezvous/channels", "!matrix-js-sdk/src/indexeddb-worker", "!matrix-js-sdk/src/pushprocessor", "!matrix-js-sdk/src/extensible_events_v1", diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 070ac5f8544..6e225467afa 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -27,7 +27,7 @@ jobs: cache: "yarn" - name: Install Deps - run: "./scripts/ci/install-deps.sh --ignore-scripts" + run: "./scripts/ci/install-deps.sh" - name: Typecheck run: "yarn run lint:types" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7089569f73e..3815c4fb4cc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,7 +47,7 @@ jobs: cache: "yarn" - name: Install Deps - run: "./scripts/ci/install-deps.sh --ignore-scripts" + run: "./scripts/ci/install-deps.sh" env: JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} diff --git a/package.json b/package.json index 34cb581025b..e6cd0b132cb 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@sentry/browser": "^7.0.0", "@testing-library/react-hooks": "^8.0.1", "@vector-im/compound-design-tokens": "^1.2.0", - "@vector-im/compound-web": "^4.3.1", + "@vector-im/compound-web": "^4.4.1", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", diff --git a/playwright/e2e/settings/general-user-settings-tab.spec.ts b/playwright/e2e/settings/general-user-settings-tab.spec.ts index ae3718aba95..ff2d7c22070 100644 --- a/playwright/e2e/settings/general-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/general-user-settings-tab.spec.ts @@ -21,8 +21,6 @@ const USER_NAME_NEW = "Alice"; const IntegrationManager = "scalar.vector.im"; test.describe("General user settings tab", () => { - let userId: string; - test.use({ displayName: USER_NAME, config: { @@ -34,18 +32,18 @@ test.describe("General user settings tab", () => { }, }); - test("should be rendered properly", async ({ uut }) => { + test("should be rendered properly", async ({ uut, user }) => { await expect(uut).toMatchScreenshot("general.png"); // Assert that the top heading is rendered await expect(uut.getByRole("heading", { name: "General" })).toBeVisible(); - const profile = uut.locator(".mx_ProfileSettings_profile"); + const profile = uut.locator(".mx_UserProfileSettings_profile"); await profile.scrollIntoViewIfNeeded(); await expect(profile.getByRole("textbox", { name: "Display Name" })).toHaveValue(USER_NAME); // Assert that a userId is rendered - await expect(profile.locator(".mx_ProfileSettings_profile_controls_userId", { hasText: userId })).toBeVisible(); + expect(uut.getByLabel("Username")).toHaveText(user.userId); // Check avatar setting const avatar = profile.locator(".mx_AvatarSetting_avatar"); @@ -131,12 +129,15 @@ test.describe("General user settings tab", () => { }); test("should support adding and removing a profile picture", async ({ uut }) => { - const profileSettings = uut.locator(".mx_ProfileSettings"); + const profileSettings = uut.locator(".mx_UserProfileSettings"); // Upload a picture await profileSettings.getByAltText("Upload").setInputFiles("playwright/sample-files/riot.png"); // Find and click "Remove" link button - await profileSettings.locator(".mx_ProfileSettings_profile").getByRole("button", { name: "Remove" }).click(); + await profileSettings + .locator(".mx_UserProfileSettings_profile") + .getByRole("button", { name: "Remove" }) + .click(); // Assert that the link button disappeared await expect( @@ -175,7 +176,7 @@ test.describe("General user settings tab", () => { test("should support changing a display name", async ({ uut, page, app }) => { // Change the diaplay name to USER_NAME_NEW const displayNameInput = uut - .locator(".mx_SettingsTab .mx_ProfileSettings") + .locator(".mx_SettingsTab .mx_UserProfileSettings") .getByRole("textbox", { name: "Display Name" }); await displayNameInput.fill(USER_NAME_NEW); await displayNameInput.press("Enter"); diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 00807a0b8a4..b7c7b7075cf 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -28,7 +28,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for `matrixdotorg/synapse` image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:13d471114317080014318b4fe41b67ffda6ac11fdfd866e03be7a77e6cb08a4e"; +const DOCKER_TAG = "develop@sha256:a832fd5bcf98a0056b23007d1ef6c749bfa922267c7638a32b447e8c14e30588"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png index e11ef9c4101..f6463ffd22b 100644 Binary files a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png and b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png differ diff --git a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png index 75febc97d7a..7f221936623 100644 Binary files a/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png and b/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 20ed9dfa392..7b5fa8e9948 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -597,7 +597,10 @@ legend { * Elements that should not be styled like a dialog button are mentioned in a :not() pseudo-class. * For the widest browser support, we use multiple :not pseudo-classes instead of :not(.a, .b). */ -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton), +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_ProfileSettings button + ), .mx_Dialog input[type="submit"], .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @@ -614,11 +617,17 @@ legend { font-family: inherit; } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):last-child { +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_ProfileSettings button + ):last-child { margin-right: 0px; } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):focus, +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_ProfileSettings button + ):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { @@ -627,7 +636,8 @@ legend { .mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog input[type="submit"].mx_Dialog_primary, -.mx_Dialog_buttons button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), +.mx_Dialog_buttons + button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_ProfileSettings button), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); background-color: var(--cpd-color-bg-action-primary-rest); @@ -637,7 +647,8 @@ legend { .mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog input[type="submit"].danger, -.mx_Dialog_buttons button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), +.mx_Dialog_buttons + button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_ProfileSettings button), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary); @@ -650,7 +661,10 @@ legend { color: var(--cpd-color-text-critical-primary); } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):disabled, +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_ProfileSettings button + ):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { diff --git a/res/css/_components.pcss b/res/css/_components.pcss index cc7e41bc998..a7c79bfbf2e 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -337,7 +337,7 @@ @import "./views/settings/_Notifications.pcss"; @import "./views/settings/_PhoneNumbers.pcss"; @import "./views/settings/_PowerLevelSelector.pcss"; -@import "./views/settings/_ProfileSettings.pcss"; +@import "./views/settings/_RoomProfileSettings.pcss"; @import "./views/settings/_SecureBackupPanel.pcss"; @import "./views/settings/_SetIdServer.pcss"; @import "./views/settings/_SetIntegrationManager.pcss"; @@ -345,6 +345,7 @@ @import "./views/settings/_SpellCheckLanguages.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; +@import "./views/settings/_UserProfileSettings.pcss"; @import "./views/settings/tabs/_SettingsBanner.pcss"; @import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss"; diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index f25c15e48e6..5f8a6a70a1b 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -207,6 +207,10 @@ limitations under the License. .mx_UserMenu_iconSignOut::before { mask-image: url("$(res)/img/element-icons/leave.svg"); } + + .mx_UserMenu_iconQr::before { + mask-image: url("@vector-im/compound-design-tokens/icons/qr-code.svg"); + } } .mx_UserMenu_CustomStatusSection { diff --git a/res/css/views/dialogs/_UserSettingsDialog.pcss b/res/css/views/dialogs/_UserSettingsDialog.pcss index 41d39f8b791..1e27bb4b6aa 100644 --- a/res/css/views/dialogs/_UserSettingsDialog.pcss +++ b/res/css/views/dialogs/_UserSettingsDialog.pcss @@ -14,6 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_SettingsDialog_toastContainer { + position: absolute; + bottom: var(--cpd-space-10x); + width: 100%; + display: flex; + justify-content: center; +} + /* ICONS */ /* ========================================================== */ diff --git a/res/css/views/settings/_ProfileSettings.pcss b/res/css/views/settings/_RoomProfileSettings.pcss similarity index 75% rename from res/css/views/settings/_ProfileSettings.pcss rename to res/css/views/settings/_RoomProfileSettings.pcss index 73cdcd75c86..8af0249ab46 100644 --- a/res/css/views/settings/_ProfileSettings.pcss +++ b/res/css/views/settings/_RoomProfileSettings.pcss @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020, 2024 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ProfileSettings { +.mx_RoomProfileSettings { border-bottom: 1px solid $quinary-content; - .mx_ProfileSettings_profile { + .mx_RoomProfileSettings_profile { display: flex; - .mx_ProfileSettings_profile_controls { + .mx_RoomProfileSettings_profile_controls { flex-grow: 1; margin-inline-end: 54px; @@ -28,7 +28,7 @@ limitations under the License. margin-top: $spacing-8; } - .mx_ProfileSettings_profile_controls_topic { + .mx_RoomProfileSettings_profile_controls_topic { margin-top: $spacing-8; & > textarea { @@ -36,18 +36,18 @@ limitations under the License. resize: vertical; } - &.mx_ProfileSettings_profile_controls_topic--room textarea { + &.mx_RoomProfileSettings_profile_controls_topic--room textarea { min-height: 4em; } } - .mx_ProfileSettings_profile_controls_userId { + .mx_RoomProfileSettings_profile_controls_userId { margin-inline-end: $spacing-20; } } } - .mx_ProfileSettings_buttons { + .mx_RoomProfileSettings_buttons { display: flex; gap: var(--cpd-space-4x); margin-top: 10px; /* 18px is already accounted for by the

above the buttons */ diff --git a/res/css/views/settings/_UserProfileSettings.pcss b/res/css/views/settings/_UserProfileSettings.pcss new file mode 100644 index 00000000000..3a9dc7dcc74 --- /dev/null +++ b/res/css/views/settings/_UserProfileSettings.pcss @@ -0,0 +1,58 @@ +/* +Copyright 2019, 2020, 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_UserProfileSettings { + border-bottom: 1px solid $quinary-content; + + .mx_UserProfileSettings_profile { + display: flex; + margin-top: var(--cpd-space-6x); + gap: 16px; + /* This is temporary until the 'Remove' link is replaced by a context menu. */ + margin-bottom: 20px; + + .mx_UserProfileSettings_profile_displayName { + flex-grow: 1; + width: 100%; + } + } + + .mx_UserProfileSettings_profile_controls { + flex-grow: 1; + } + + .mx_UserProfileSettings_profile_controls_userId { + width: 100%; + .mx_CopyableText { + margin-top: var(--cpd-space-1x); + width: 100%; + box-sizing: border-box; + } + } + + .mx_UserProfileSettings_profile_controls_userId_label { + font-size: 15px; + font-weight: 500; + } +} + +@media (max-width: 768px) { + .mx_UserProfileSettings_profile { + flex-direction: column; + align-items: center; + gap: 30px; + } +} diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index cc336cc5cef..548555cd1dd 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -158,10 +158,16 @@ export class DecryptionFailureTracker { * * @param {function} errorCodeMapFn The function used to map decryption failure reason codes to the * `trackedErrorCode`. + * + * @param {boolean} checkReportedEvents Check if we have already reported an event. + * Defaults to `true`. This is only used for tests, to avoid possible false positives from + * the Bloom filter. This should be set to `false` for all tests except for those + * that specifically test the `reportedEvents` functionality. */ private constructor( private readonly fn: TrackingFn, private readonly errorCodeMapFn: ErrCodeMapFn, + private readonly checkReportedEvents: boolean = true, ) { if (!fn || typeof fn !== "function") { throw new Error("DecryptionFailureTracker requires tracking function"); @@ -214,7 +220,7 @@ export class DecryptionFailureTracker { const eventId = e.getId()!; // if it's already reported, we don't need to do anything - if (this.reportedEvents.has(eventId)) { + if (this.reportedEvents.has(eventId) && this.checkReportedEvents) { return; } @@ -240,7 +246,7 @@ export class DecryptionFailureTracker { const eventId = e.getId()!; // if it's already reported, we don't need to do anything - if (this.reportedEvents.has(eventId)) { + if (this.reportedEvents.has(eventId) && this.checkReportedEvents) { return; } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 5e50f68b48c..4049248111f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -764,7 +764,7 @@ export default class MatrixChat extends React.PureComponent { const tabPayload = payload as OpenToTabPayload; Modal.createDialog( UserSettingsDialog, - { initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores }, + { ...payload.props, initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores }, /*className=*/ undefined, /*isPriority=*/ false, /*isStatic=*/ true, diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 2e8b5d91a3c..0e6b17ccc07 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { createRef, ReactNode } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { discoverAndValidateOIDCIssuerWellKnown, Room } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -52,6 +52,8 @@ import { Icon as LiveIcon } from "../../../res/img/compound/live-8px.svg"; import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast"; import { SDKContext } from "../../contexts/SDKContext"; import { shouldShowFeedback } from "../../utils/Feedback"; +import { shouldShowQr } from "../views/settings/devices/LoginWithQRSection"; +import { Features } from "../../settings/Settings"; interface IProps { isPanelCollapsed: boolean; @@ -66,6 +68,8 @@ interface IState { isHighContrast: boolean; selectedSpace?: Room | null; showLiveAvatarAddon: boolean; + showQrLogin: boolean; + supportsQrLogin: boolean; } const toRightOf = (rect: PartialDOMRect): MenuProps => { @@ -103,6 +107,8 @@ export default class UserMenu extends React.Component { isHighContrast: this.isUserOnHighContrastTheme(), selectedSpace: SpaceStore.instance.activeSpaceRoom, showLiveAvatarAddon: this.context.voiceBroadcastRecordingsStore.hasCurrent(), + showQrLogin: false, + supportsQrLogin: false, }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); @@ -126,6 +132,7 @@ export default class UserMenu extends React.Component { ); this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); + this.checkQrLoginSupport(); } public componentWillUnmount(): void { @@ -140,6 +147,29 @@ export default class UserMenu extends React.Component { ); } + private checkQrLoginSupport = async (): Promise => { + if (!this.context.client || !SettingsStore.getValue(Features.OidcNativeFlow)) return; + + const { issuer } = await this.context.client.getAuthIssuer().catch(() => ({ issuer: undefined })); + if (issuer) { + const [oidcClientConfig, versions, wellKnown, isCrossSigningReady] = await Promise.all([ + discoverAndValidateOIDCIssuerWellKnown(issuer), + this.context.client.getVersions(), + this.context.client.waitForClientWellKnown(), + this.context.client.getCrypto()?.isCrossSigningReady(), + ]); + + const supportsQrLogin = shouldShowQr( + this.context.client, + !!isCrossSigningReady, + oidcClientConfig, + versions, + wellKnown, + ); + this.setState({ supportsQrLogin, showQrLogin: true }); + } + }; + private isUserOnDarkTheme(): boolean { if (SettingsStore.getValue("use_system_theme")) { return window.matchMedia("(prefers-color-scheme: dark)").matches; @@ -237,11 +267,11 @@ export default class UserMenu extends React.Component { SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab }; - private onSettingsOpen = (ev: ButtonEvent, tabId?: string): void => { + private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record): void => { ev.preventDefault(); ev.stopPropagation(); - const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId }; + const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId, props }; defaultDispatcher.dispatch(payload); this.setState({ contextMenuPosition: null }); // also close the menu }; @@ -363,9 +393,33 @@ export default class UserMenu extends React.Component { ); } + let linkNewDeviceButton: JSX.Element | undefined; + if (this.state.showQrLogin) { + const extraProps: Omit< + React.ComponentProps, + "iconClassname" | "label" | "onClick" + > = {}; + if (!this.state.supportsQrLogin) { + extraProps.disabled = true; + extraProps.title = _t("user_menu|link_new_device_not_supported"); + extraProps.caption = _t("user_menu|link_new_device_not_supported_caption"); + extraProps.placement = "right"; + } + + linkNewDeviceButton = ( + this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })} + /> + ); + } + let primaryOptionList = ( {homeButton} + {linkNewDeviceButton} { + private finished = false; + public constructor(props: IProps) { super(props); @@ -66,6 +94,10 @@ export default class LoginWithQR extends React.Component { }; } + private get ourIntent(): RendezvousIntent { + return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + } + public componentDidMount(): void { this.updateMode(this.props.mode).then(() => {}); } @@ -81,27 +113,36 @@ export default class LoginWithQR extends React.Component { if (this.state.rendezvous) { const rendezvous = this.state.rendezvous; rendezvous.onFailure = undefined; - await rendezvous.cancel(RendezvousFailureReason.UserCancelled); + if (rendezvous instanceof MSC3906Rendezvous) { + await rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled); + } this.setState({ rendezvous: undefined }); } if (mode === Mode.Show) { - await this.generateCode(); + await this.generateAndShowCode(); } } public componentWillUnmount(): void { - if (this.state.rendezvous) { + if (this.state.rendezvous && !this.finished) { // eslint-disable-next-line react/no-direct-mutation-state this.state.rendezvous.onFailure = undefined; // calling cancel will call close() as well to clean up the resources - this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled).then(() => {}); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + this.state.rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled); + } } } - private approveLogin = async (): Promise => { - if (!this.state.rendezvous) { + private async legacyApproveLogin(): Promise { + if (!(this.state.rendezvous instanceof MSC3906Rendezvous)) { throw new Error("Rendezvous not found"); } + if (!this.props.client) { + throw new Error("No client to approve login with"); + } this.setState({ phase: Phase.Loading }); try { @@ -121,7 +162,7 @@ export default class LoginWithQR extends React.Component { } if (!this.props.client.getCrypto()) { // no E2EE to set up - this.props.onFinished(true); + this.onFinished(true); return; } this.setState({ phase: Phase.Verifying }); @@ -132,7 +173,7 @@ export default class LoginWithQR extends React.Component { } finally { this.setState({ rendezvous: undefined }); } - this.props.onFinished(true); + this.onFinished(true); } catch (e) { logger.error("Error whilst approving sign in", e); if (e instanceof HTTPError && e.httpStatus === 429) { @@ -140,27 +181,38 @@ export default class LoginWithQR extends React.Component { this.setState({ phase: Phase.Error, failureReason: LoginWithQRFailureReason.RateLimited }); return; } - this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); } - }; + } - private generateCode = async (): Promise => { - let rendezvous: MSC3906Rendezvous; - try { - const fallbackRzServer = this.props.client.getClientWellKnown()?.["io.element.rendezvous"]?.server; - const transport = new MSC3886SimpleHttpRendezvousTransport({ - onFailure: this.onFailure, - client: this.props.client, - fallbackRzServer, - }); + private onFinished(success: boolean): void { + this.finished = true; + this.props.onFinished(success); + } - const channel = new MSC3903ECDHv2RendezvousChannel( - transport, - undefined, - this.onFailure, - ); + private generateAndShowCode = async (): Promise => { + let rendezvous: MSC4108SignInWithQR | MSC3906Rendezvous; + try { + const fallbackRzServer = this.props.client?.getClientWellKnown()?.["io.element.rendezvous"]?.server; - rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + if (this.props.legacy) { + const transport = new MSC3886SimpleHttpRendezvousTransport({ + onFailure: this.onFailure, + client: this.props.client, + fallbackRzServer, + }); + const channel = new MSC3903ECDHv2RendezvousChannel(transport, undefined, this.onFailure); + rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + } else { + const transport = new MSC4108RendezvousSession({ + onFailure: this.onFailure, + client: this.props.client, + fallbackRzServer, + }); + await transport.send(""); + const channel = new MSC4108SecureChannel(transport, undefined, this.onFailure); + rendezvous = new MSC4108SignInWithQR(channel, false, this.props.client, this.onFailure); + } await rendezvous.generateCode(); this.setState({ @@ -170,23 +222,84 @@ export default class LoginWithQR extends React.Component { }); } catch (e) { logger.error("Error whilst generating QR code", e); - this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.HomeserverLacksSupport }); + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport }); return; } try { - const confirmationDigits = await rendezvous.startAfterShowingCode(); - this.setState({ phase: Phase.Connected, confirmationDigits }); - } catch (e) { - logger.error("Error whilst doing QR login", e); - // only set to error phase if it hasn't already been set by onFailure or similar - if (this.state.phase !== Phase.Error) { - this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + if (rendezvous instanceof MSC3906Rendezvous) { + const confirmationDigits = await rendezvous.startAfterShowingCode(); + this.setState({ phase: Phase.LegacyConnected, confirmationDigits }); + } else if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) { + // MSC4108-Flow: NewScanned + await rendezvous.negotiateProtocols(); + const { verificationUri } = await rendezvous.deviceAuthorizationGrant(); + this.setState({ + phase: Phase.OutOfBandConfirmation, + verificationUri, + }); } + + // we ask the user to confirm that the channel is secure + } catch (e: RendezvousError | unknown) { + logger.error("Error whilst approving login", e); + if (rendezvous instanceof MSC3906Rendezvous) { + // only set to error phase if it hasn't already been set by onFailure or similar + if (this.state.phase !== Phase.Error) { + this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown }); + } + } else { + await rendezvous?.cancel( + e instanceof RendezvousError + ? (e.code as MSC4108FailureReason) + : ClientRendezvousFailureReason.Unknown, + ); + } + } + }; + + private approveLogin = async (checkCode: string | undefined): Promise => { + if (!(this.state.rendezvous instanceof MSC4108SignInWithQR)) { + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + throw new Error("Rendezvous not found"); + } + + if (!this.state.lastScannedCode && this.state.rendezvous?.checkCode !== checkCode) { + this.setState({ failureReason: LoginWithQRFailureReason.CheckCodeMismatch }); + return; + } + + try { + if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) { + // MSC4108-Flow: NewScanned + this.setState({ phase: Phase.Loading }); + + if (this.state.verificationUri) { + window.open(this.state.verificationUri, "_blank"); + } + + this.setState({ phase: Phase.WaitingForDevice }); + + // send secrets + await this.state.rendezvous.shareSecrets(); + + // done + this.onFinished(true); + } else { + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + throw new Error("New device flows around OIDC are not yet implemented"); + } + } catch (e: RendezvousError | unknown) { + logger.error("Error whilst approving sign in", e); + this.setState({ + phase: Phase.Error, + failureReason: e instanceof RendezvousError ? e.code : ClientRendezvousFailureReason.Unknown, + }); } }; private onFailure = (reason: RendezvousFailureReason): void => { + if (this.state.phase === Phase.Error) return; // Already in failed state logger.info(`Rendezvous failed: ${reason}`); this.setState({ phase: Phase.Error, failureReason: reason }); }; @@ -195,44 +308,72 @@ export default class LoginWithQR extends React.Component { this.setState({ rendezvous: undefined, confirmationDigits: undefined, + verificationUri: undefined, failureReason: undefined, + userCode: undefined, + checkCode: undefined, + homeserverBaseUrl: undefined, + lastScannedCode: undefined, + mediaPermissionError: false, }); } - private onClick = async (type: Click): Promise => { + private onClick = async (type: Click, checkCode?: string): Promise => { switch (type) { case Click.Cancel: - await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled); + } this.reset(); - this.props.onFinished(false); + this.onFinished(false); break; case Click.Approve: - await this.approveLogin(); + await (this.props.legacy ? this.legacyApproveLogin() : this.approveLogin(checkCode)); break; case Click.Decline: await this.state.rendezvous?.declineLoginOnExistingDevice(); this.reset(); - this.props.onFinished(false); - break; - case Click.TryAgain: - this.reset(); - await this.updateMode(this.props.mode); + this.onFinished(false); break; case Click.Back: - await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); - this.props.onFinished(false); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled); + } + this.onFinished(false); + break; + case Click.ShowQr: + await this.updateMode(Mode.Show); break; } }; public render(): React.ReactNode { + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + return ( + + ); + } + return ( ); } diff --git a/src/components/views/auth/LoginWithQRFlow.tsx b/src/components/views/auth/LoginWithQRFlow.tsx index 6a6b78a29b8..036dc1b451f 100644 --- a/src/components/views/auth/LoginWithQRFlow.tsx +++ b/src/components/views/auth/LoginWithQRFlow.tsx @@ -14,12 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; -import { RendezvousFailureReason as LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous"; +import React, { createRef, ReactNode } from "react"; +import { + ClientRendezvousFailureReason, + LegacyRendezvousFailureReason, + MSC4108FailureReason, +} from "matrix-js-sdk/src/rendezvous"; import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg"; import { Icon as CheckCircleSolidIcon } from "@vector-im/compound-design-tokens/icons/check-circle-solid.svg"; import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg"; -import { Heading, Text } from "@vector-im/compound-web"; +import { Heading, MFAInput, Text } from "@vector-im/compound-web"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; @@ -30,13 +34,24 @@ import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; import { Click, Phase } from "./LoginWithQR-types"; import SdkConfig from "../../../SdkConfig"; import { FailureReason, LoginWithQRFailureReason } from "./LoginWithQR"; +import { XOR } from "../../../@types/common"; +import { ErrorMessage } from "../../structures/ErrorMessage"; + +/** + * @deprecated the MSC3906 implementation is deprecated in favour of MSC4108. + */ +interface MSC3906Props extends Pick { + code?: string; + confirmationDigits?: string; +} interface Props { phase: Phase; - code?: string; - onClick(type: Click): Promise; + code?: Uint8Array; + onClick(type: Click, checkCodeEntered?: string): Promise; failureReason?: FailureReason; - confirmationDigits?: string; + userCode?: string; + checkCode?: string; } // n.b MSC3886/MSC3903/MSC3906 that this is based on are now closed. @@ -46,17 +61,19 @@ interface Props { /** * A component that implements the UI for sign in and E2EE set up with a QR code. * - * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + * This supports the unstable features of MSC3906 and MSC4108 */ -export default class LoginWithQRFlow extends React.Component { - public constructor(props: Props) { +export default class LoginWithQRFlow extends React.Component> { + private checkCodeInput = createRef(); + + public constructor(props: XOR) { super(props); } private handleClick = (type: Click): ((e: React.FormEvent) => Promise) => { return async (e: React.FormEvent): Promise => { e.preventDefault(); - await this.props.onClick(type); + await this.props.onClick(type, type === Click.Approve ? this.checkCodeInput.current?.value : undefined); }; }; @@ -90,24 +107,26 @@ export default class LoginWithQRFlow extends React.Component { let message: ReactNode | undefined; switch (this.props.failureReason) { - case LegacyRendezvousFailureReason.UnsupportedAlgorithm: - case LegacyRendezvousFailureReason.UnsupportedTransport: - case LegacyRendezvousFailureReason.HomeserverLacksSupport: + case MSC4108FailureReason.UnsupportedProtocol: + case LegacyRendezvousFailureReason.UnsupportedProtocol: title = _t("auth|qr_code_login|error_unsupported_protocol_title"); message = _t("auth|qr_code_login|error_unsupported_protocol"); break; + case MSC4108FailureReason.UserCancelled: case LegacyRendezvousFailureReason.UserCancelled: title = _t("auth|qr_code_login|error_user_cancelled_title"); message = _t("auth|qr_code_login|error_user_cancelled"); break; + case MSC4108FailureReason.AuthorizationExpired: + case ClientRendezvousFailureReason.Expired: case LegacyRendezvousFailureReason.Expired: title = _t("auth|qr_code_login|error_expired_title"); message = _t("auth|qr_code_login|error_expired"); break; - case LegacyRendezvousFailureReason.InvalidCode: + case ClientRendezvousFailureReason.InsecureChannelDetected: title = _t("auth|qr_code_login|error_insecure_channel_detected_title"); message = ( <> @@ -125,13 +144,13 @@ export default class LoginWithQRFlow extends React.Component { ); break; - case LegacyRendezvousFailureReason.OtherDeviceAlreadySignedIn: + case ClientRendezvousFailureReason.OtherDeviceAlreadySignedIn: success = true; 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"); break; - case LegacyRendezvousFailureReason.UserDeclined: + case ClientRendezvousFailureReason.UserDeclined: title = _t("auth|qr_code_login|error_user_declined_title"); message = _t("auth|qr_code_login|error_user_declined"); break; @@ -141,8 +160,16 @@ export default class LoginWithQRFlow extends React.Component { message = _t("auth|qr_code_login|error_rate_limited"); break; - case LegacyRendezvousFailureReason.OtherDeviceNotSignedIn: - case LegacyRendezvousFailureReason.Unknown: + case ClientRendezvousFailureReason.ETagMissing: + title = _t("error|something_went_wrong"); + message = _t("auth|qr_code_login|error_etag_missing"); + break; + + case MSC4108FailureReason.DeviceAlreadyExists: + case MSC4108FailureReason.DeviceNotFound: + case MSC4108FailureReason.UnexpectedMessageReceived: + case ClientRendezvousFailureReason.OtherDeviceNotSignedIn: + case ClientRendezvousFailureReason.Unknown: default: title = _t("error|something_went_wrong"); message = _t("auth|qr_code_login|error_unexpected"); @@ -150,18 +177,6 @@ export default class LoginWithQRFlow extends React.Component { } className = "mx_LoginWithQR_error"; backButton = false; - buttons = ( - <> - - {_t("action|try_again")} - - {this.cancelButton()} - - ); main = ( <>

{ ); break; } - case Phase.Connected: + case Phase.LegacyConnected: backButton = false; main = ( <> @@ -213,9 +228,62 @@ export default class LoginWithQRFlow extends React.Component { ); break; + case Phase.OutOfBandConfirmation: + backButton = false; + main = ( + <> + + {_t("auth|qr_code_login|check_code_heading")} + + {_t("auth|qr_code_login|check_code_explainer")} + + + + + ); + + buttons = ( + <> + + {_t("action|continue")} + + + {_t("action|cancel")} + + + ); + break; case Phase.ShowingQR: if (this.props.code) { - const data = Buffer.from(this.props.code ?? ""); + const data = + typeof this.props.code !== "string" ? this.props.code : Buffer.from(this.props.code ?? ""); main = ( <> @@ -249,12 +317,19 @@ export default class LoginWithQRFlow extends React.Component { case Phase.Loading: main = this.simpleSpinner(); break; - case Phase.Connecting: - main = this.simpleSpinner(_t("auth|qr_code_login|connecting")); - buttons = this.cancelButton(); - break; case Phase.WaitingForDevice: - main = this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device")); + main = ( + <> + {this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))} + {this.props.userCode ? ( +
+

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

+

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

+

{this.props.userCode}

+
+ ) : null} + + ); buttons = this.cancelButton(); break; case Phase.Verifying: diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index bb97b36fc96..9aafeca2fd3 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -15,7 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import { Toast } from "@vector-im/compound-web"; +import React, { useState } from "react"; import TabbedView, { Tab, useActiveTabWithDefault } from "../../structures/TabbedView"; import { _t, _td } from "../../../languageHandler"; @@ -38,9 +39,11 @@ import { UserTab } from "./UserTab"; import { NonEmptyArray } from "../../../@types/common"; import { SDKContext, SdkContextClass } from "../../../contexts/SDKContext"; import { useSettingValue } from "../../../hooks/useSettings"; +import { ToastContext, useActiveToast } from "../../../contexts/ToastContext"; interface IProps { initialTabId?: UserTab; + showMsc4108QrCode?: boolean; sdkContext: SdkContextClass; onFinished(): void; } @@ -80,6 +83,8 @@ function titleForTabID(tabId: UserTab): React.ReactNode { export default function UserSettingsDialog(props: IProps): JSX.Element { const voipEnabled = useSettingValue(UIFeature.Voip); const mjolnirEnabled = useSettingValue("feature_mjolnir"); + // store this prop in state as changing tabs back and forth should clear it + const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode); const getTabs = (): NonEmptyArray> => { const tabs: Tab[] = []; @@ -98,7 +103,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { UserTab.SessionManager, _td("settings|sessions|title"), "mx_UserSettingsDialog_sessionsIcon", - , + , undefined, ), ); @@ -205,29 +210,41 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { return tabs as NonEmptyArray>; }; - const [activeTabId, setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.General, props.initialTabId); + const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.General, props.initialTabId); + const setActiveTabId = (tabId: UserTab): void => { + _setActiveTabId(tabId); + // Clear this so switching away from the tab and back to it will not show the QR code again + setShowMsc4108QrCode(false); + }; + + const [activeToast, toastRack] = useActiveToast(); return ( // XXX: SDKContext is provided within the LoggedInView subtree. // Modals function outside the MatrixChat React tree, so sdkContext is reprovided here to simulate that. // The longer term solution is to move our ModalManager into the React tree to inherit contexts properly. - -
- -
-
+ + +
+ +
+
+ {activeToast && {activeToast}} +
+
+
); } diff --git a/src/components/views/elements/CopyableText.tsx b/src/components/views/elements/CopyableText.tsx index 5d9946d2c1b..994d81607b9 100644 --- a/src/components/views/elements/CopyableText.tsx +++ b/src/components/views/elements/CopyableText.tsx @@ -22,14 +22,14 @@ import { _t } from "../../../languageHandler"; import { copyPlaintext } from "../../../utils/strings"; import AccessibleButton, { ButtonEvent } from "./AccessibleButton"; -interface IProps { +interface IProps extends React.HTMLAttributes { children?: React.ReactNode; getTextToCopy: () => string | null; border?: boolean; className?: string; } -const CopyableText: React.FC = ({ children, getTextToCopy, border = true, className }) => { +const CopyableText: React.FC = ({ children, getTextToCopy, border = true, className, ...props }) => { const [tooltip, setTooltip] = useState(undefined); const onCopyClickInternal = async (e: ButtonEvent): Promise => { @@ -50,7 +50,7 @@ const CopyableText: React.FC = ({ children, getTextToCopy, border = true }); return ( -
+
{children} let profileSettingsButtons; if (this.state.canSetName || this.state.canSetTopic || this.state.canSetAvatar) { profileSettingsButtons = ( -
+
} return ( -
-
-
+ +
+
/> = ({ avatar, avatarAltText, onChange, remo } }, [avatar]); - // TODO: Use useId() as soon as we're using React 18. // Prevents ID collisions when this component is used more than once on the same page. - const a11yId = useRef(`hover-text-${Math.random()}`); + const a11yId = useId(); const onFileChanged = useCallback( (e: React.ChangeEvent) => { @@ -95,7 +95,7 @@ const AvatarSetting: React.FC = ({ avatar, avatarAltText, onChange, remo element="div" onClick={uploadAvatar} className="mx_AvatarSetting_avatarPlaceholder mx_AvatarSetting_avatarDisplay" - aria-labelledby={disabled ? undefined : a11yId.current} + aria-labelledby={disabled ? undefined : a11yId} // Inhibit tab stop as we have explicit upload/remove buttons tabIndex={-1} /> @@ -122,7 +122,7 @@ const AvatarSetting: React.FC = ({ avatar, avatarAltText, onChange, remo = ({ avatar, avatarAltText, onChange, remo {avatarElement}