You've already forked element-web
mirror of
https://github.com/element-hq/element-web.git
synced 2026-10-23 11:09:01 +03:00
Drop support for migrating libolm sessions (#35138)
* Remove the libolm to rust crypto store migration * Add LegacyCryptoUnsupportedView to shared-components * Show LegacyCryptoUnsupportedView when an unmigrated libolm crypto store is found * Replace crypto migration e2e test with legacy crypto store test * Cleanup loadSession, reduce congivitive complexity. * Improve centering and spacing of view * update snap * changes for review * lint * Don't format the dump file * Re-add missing files
This commit is contained in:
@@ -43,7 +43,7 @@
|
||||
// Downloaded and already minified,
|
||||
"/apps/web/res/jitsi_external_api.min.js",
|
||||
// This file is also machine-generated,
|
||||
"/apps/web/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json",
|
||||
"/apps/web/playwright/e2e/crypto/test_legacy_indexeddb_cryptostore_dump/dump.json",
|
||||
"/apps/web/playwright/test-results/",
|
||||
"/apps/web/playwright/html-report/",
|
||||
"/apps/web/playwright/logs/",
|
||||
|
||||
43
apps/web/playwright/e2e/crypto/legacy-crypto-store.spec.ts
Normal file
43
apps/web/playwright/e2e/crypto/legacy-crypto-store.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, test } from "../../element-web-test";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
test.describe("legacy crypto store", function () {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
|
||||
// Replace the `user` fixture with one which populates the legacy indexeddb data before starting the app.
|
||||
user: async ({ context, pageWithCredentials: page, credentials }, use) => {
|
||||
await page.route(`/test_legacy_indexeddb_cryptostore_dump/*`, async (route, request) => {
|
||||
const resourcePath = path.join(__dirname, new URL(request.url()).pathname);
|
||||
const body = await readFile(resourcePath, { encoding: "utf-8" });
|
||||
await route.fulfill({ body });
|
||||
});
|
||||
await page.goto("/test_legacy_indexeddb_cryptostore_dump/index.html");
|
||||
|
||||
await use(credentials);
|
||||
},
|
||||
});
|
||||
|
||||
test("Should explain that a device with an unmigrated legacy crypto store is too old", async ({
|
||||
context,
|
||||
user,
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.getByRole("heading", { name: "This device cannot be used" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Remove this device" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, test } from "../../element-web-test";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
test.describe("migration", { tag: "@no-webkit" }, function () {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
|
||||
// Replace the `user` fixture with one which populates the indexeddb data before starting the app.
|
||||
user: async ({ context, pageWithCredentials: page, credentials }, use) => {
|
||||
await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => {
|
||||
const resourcePath = path.join(__dirname, new URL(request.url()).pathname);
|
||||
const body = await readFile(resourcePath, { encoding: "utf-8" });
|
||||
await route.fulfill({ body });
|
||||
});
|
||||
await page.goto("/test_indexeddb_cryptostore_dump/index.html");
|
||||
|
||||
await use(credentials);
|
||||
},
|
||||
});
|
||||
|
||||
test("Should support migration from legacy crypto", async ({ context, user, page }, workerInfo) => {
|
||||
test.slow();
|
||||
|
||||
// We should see a migration progress bar
|
||||
await page.getByText("Hang tight.").waitFor({ timeout: 60000 });
|
||||
|
||||
// When the progress bar first loads, it should have a high max (one per megolm session to import), and
|
||||
// a relatively low value.
|
||||
const progressBar = page.getByRole("progressbar");
|
||||
const initialProgress = parseFloat((await progressBar.getAttribute("value"))!);
|
||||
const initialMax = parseFloat((await progressBar.getAttribute("max"))!);
|
||||
expect(initialMax).toBeGreaterThan(4000);
|
||||
expect(initialProgress).toBeGreaterThanOrEqual(0);
|
||||
expect(initialProgress).toBeLessThanOrEqual(500);
|
||||
|
||||
// Later, the progress should pass 50%
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const progressBar = page.getByRole("progressbar");
|
||||
return (
|
||||
(parseFloat((await progressBar.getAttribute("value"))!) * 100.0) /
|
||||
parseFloat((await progressBar.getAttribute("max"))!)
|
||||
);
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
)
|
||||
.toBeGreaterThan(50);
|
||||
|
||||
// Eventually, we should get a normal matrix chat
|
||||
await page.waitForSelector(".mx_MatrixChat", { timeout: 120000 });
|
||||
});
|
||||
});
|
||||
@@ -6,23 +6,6 @@ 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.
|
||||
*/
|
||||
|
||||
.mx_LoginSplashView_migrationProgress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.mx_ProgressBar {
|
||||
height: 8px;
|
||||
width: 600px;
|
||||
|
||||
@mixin ProgressBarBorderRadius 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_LoginSplashView_splashButtons {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
@@ -26,6 +26,7 @@ import ActiveWidgetStore from "./stores/ActiveWidgetStore";
|
||||
import PlatformPeg from "./PlatformPeg";
|
||||
import { sendLoginRequest } from "./Login";
|
||||
import * as StorageManager from "./utils/StorageManager";
|
||||
import { LegacyCryptoStoreError } from "./utils/LegacyCryptoStoreError.ts";
|
||||
import * as StorageAccess from "./utils/StorageAccess";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
@@ -133,15 +134,105 @@ function checkSessionLock(): void {
|
||||
class SessionLockStolenError extends Error {}
|
||||
|
||||
interface ILoadSessionOpts {
|
||||
/** Set to true to enable guest access tokens and auto-guest registrations. */
|
||||
enableGuest?: boolean;
|
||||
/** Homeserver URL. Only used if `enableGuest` is true; defines the HS to register against. */
|
||||
guestHsUrl?: string;
|
||||
/** Identity server URL. Only used if `enableGuest` is true; defines the IS to use. */
|
||||
guestIsUrl?: string;
|
||||
/** If the stored session is a guest account, ignore it and don't load it. */
|
||||
ignoreGuest?: boolean;
|
||||
/** Default display name to use when registering as a guest. */
|
||||
defaultDeviceDisplayName?: string;
|
||||
/** The parameters read in at app load time from the URL. */
|
||||
urlParams?: URLParams;
|
||||
/**
|
||||
* Signal that is aborted if the server logs this session out while we are loading it (for example because the
|
||||
* access token expired). If loading then fails, the error is ignored and `loadSession` resolves to `false`.
|
||||
*/
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the homeserver URL to use for guest access.
|
||||
*
|
||||
* @returns the URL, or undefined if guest access is disabled or no homeserver URL was given.
|
||||
*/
|
||||
function getGuestHsUrl(opts: ILoadSessionOpts): string | undefined {
|
||||
if (!opts.enableGuest) return undefined;
|
||||
if (!opts.guestHsUrl) {
|
||||
logger.warn("Cannot enable guest access: can't determine HS URL to use");
|
||||
return undefined;
|
||||
}
|
||||
return opts.guestHsUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load the session, taking into account whether guest
|
||||
* parameters were provided or the session lock had been used.
|
||||
*/
|
||||
async function tryLoadSession(opts: ILoadSessionOpts): Promise<boolean> {
|
||||
const guestHsUrl = getGuestHsUrl(opts);
|
||||
const guestCreds = opts.urlParams?.guest;
|
||||
|
||||
if (guestHsUrl && guestCreds?.guest_user_id && guestCreds.guest_access_token) {
|
||||
logger.log("Using guest access credentials");
|
||||
await doSetLoggedIn(
|
||||
{
|
||||
userId: guestCreds.guest_user_id,
|
||||
accessToken: guestCreds.guest_access_token,
|
||||
homeserverUrl: guestHsUrl,
|
||||
identityServerUrl: opts.guestIsUrl,
|
||||
guest: true,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await restoreSessionFromStorage({ ignoreGuest: Boolean(opts.ignoreGuest) })) {
|
||||
return true;
|
||||
}
|
||||
if (sessionLockStolen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (guestHsUrl) {
|
||||
return registerAsGuest(guestHsUrl, opts.guestIsUrl, opts.defaultDeviceDisplayName);
|
||||
}
|
||||
|
||||
// fall back to welcome screen
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error thrown by {@link tryLoadSession}.
|
||||
*
|
||||
* @returns `false` to fall back to the welcome screen, or the result of {@link handleLoadSessionFailure}.
|
||||
* @throws {@link LegacyCryptoStoreError} so that MatrixChat can show a dedicated error screen for it.
|
||||
*/
|
||||
async function handleLoadSessionError(e: unknown, opts: ILoadSessionOpts): Promise<boolean> {
|
||||
// We may be aborted e.g. because our token expired, so don't show an error here
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e instanceof LegacyCryptoStoreError) {
|
||||
// This session predates the rust crypto stack and cannot be migrated. Let this
|
||||
// propagate up to MatrixChat, which shows a dedicated error screen for it.
|
||||
throw e;
|
||||
}
|
||||
|
||||
// If we're aborting login because of a storage inconsistency, or the session lock was stolen while we were
|
||||
// trying to start, we don't need to show the general failure dialog. Instead, just go back to welcome.
|
||||
if (e instanceof AbortLoginAndRebuildStorage || sessionLockStolen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return handleLoadSessionFailure(e, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at startup, to attempt to build a logged-in Matrix session. It tries
|
||||
* a number of things:
|
||||
@@ -155,85 +246,15 @@ interface ILoadSessionOpts {
|
||||
* If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in
|
||||
* turn will raise on_logged_in and will_start_client events.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.fragmentQueryParams]: string->string map of the
|
||||
* query-parameters extracted from the #-fragment of the starting URI.
|
||||
* @param {boolean} [opts.enableGuest]: set to true to enable guest access
|
||||
* tokens and auto-guest registrations.
|
||||
* @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest
|
||||
* is true; defines the HS to register against.
|
||||
* @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest
|
||||
* is true; defines the IS to use.
|
||||
* @param {bool} [opts.ignoreGuest]: If the stored session is a guest account,
|
||||
* ignore it and don't load it.
|
||||
* @param {string} [opts.defaultDeviceDisplayName]: Default display name to use
|
||||
* when registering as a guest.
|
||||
* @returns {Promise} a promise which resolves when the above process completes.
|
||||
* @returns A promise which resolves when the above process completes.
|
||||
* Resolves to `true` if we ended up starting a session, or `false` if we
|
||||
* failed.
|
||||
*/
|
||||
export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean> {
|
||||
try {
|
||||
let enableGuest = opts.enableGuest || false;
|
||||
const guestHsUrl = opts.guestHsUrl;
|
||||
const guestIsUrl = opts.guestIsUrl;
|
||||
const urlParams = opts.urlParams;
|
||||
const defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
||||
|
||||
if (enableGuest && !guestHsUrl) {
|
||||
logger.warn("Cannot enable guest access: can't determine HS URL to use");
|
||||
enableGuest = false;
|
||||
}
|
||||
|
||||
if (enableGuest && guestHsUrl && urlParams?.guest?.guest_user_id && urlParams?.guest?.guest_access_token) {
|
||||
logger.log("Using guest access credentials");
|
||||
await doSetLoggedIn(
|
||||
{
|
||||
userId: urlParams.guest.guest_user_id,
|
||||
accessToken: urlParams.guest.guest_access_token,
|
||||
homeserverUrl: guestHsUrl,
|
||||
identityServerUrl: guestIsUrl,
|
||||
guest: true,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const success = await restoreSessionFromStorage({
|
||||
ignoreGuest: Boolean(opts.ignoreGuest),
|
||||
});
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
if (sessionLockStolen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enableGuest && guestHsUrl) {
|
||||
return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
|
||||
}
|
||||
|
||||
// fall back to welcome screen
|
||||
return false;
|
||||
return await tryLoadSession(opts);
|
||||
} catch (e) {
|
||||
// We may be aborted e.g. because our token expired, so don't show an error here
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e instanceof AbortLoginAndRebuildStorage) {
|
||||
// If we're aborting login because of a storage inconsistency, we don't
|
||||
// need to show the general failure dialog. Instead, just go back to welcome.
|
||||
return false;
|
||||
}
|
||||
|
||||
// likewise, if the session lock has been stolen while we've been trying to start
|
||||
if (sessionLockStolen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return handleLoadSessionFailure(e, opts);
|
||||
return handleLoadSessionError(e, opts);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { advanceDateAndTime, stubClient, createTestClient } from "test-utils";
|
||||
import { type IMatrixClientPeg, MatrixClientPeg as peg } from "./MatrixClientPeg";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import { createClientWithCreds } from "./utils/createMatrixClient";
|
||||
import * as StorageManager from "./utils/StorageManager";
|
||||
import { LegacyCryptoStoreError } from "./utils/LegacyCryptoStoreError.ts";
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -112,13 +114,21 @@ describe("MatrixClientPeg", () => {
|
||||
expect(mockStartDehydration).toHaveBeenCalledWith({ onlyIfKeyCached: true, rehydrate: false });
|
||||
});
|
||||
|
||||
it("Should migrate existing login", async () => {
|
||||
it("should initialise the rust crypto library for an existing login", async () => {
|
||||
const mockInitRustCrypto = vi.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
|
||||
|
||||
await testPeg.start();
|
||||
expect(mockInitRustCrypto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should refuse to start a session which still has an unmigrated legacy crypto store", async () => {
|
||||
vi.spyOn(StorageManager, "hasUnmigratedLegacyCryptoStore").mockResolvedValue(true);
|
||||
const mockInitRustCrypto = vi.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
|
||||
|
||||
await expect(testPeg.start()).rejects.toThrow(LegacyCryptoStoreError);
|
||||
expect(mockInitRustCrypto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should poll the client well-known by default", async () => {
|
||||
vi.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
|
||||
const startClient = vi.spyOn(testPeg.safeGet(), "startClient").mockResolvedValue(undefined);
|
||||
|
||||
@@ -27,6 +27,7 @@ import PlatformPeg from "./PlatformPeg";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts";
|
||||
import { initialiseDehydrationIfEnabled } from "./utils/device/dehydration";
|
||||
import { LegacyCryptoStoreError } from "./utils/LegacyCryptoStoreError.ts";
|
||||
|
||||
export interface MatrixClientPegAssignOpts {
|
||||
/**
|
||||
@@ -311,6 +312,12 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
||||
logger.error("Warning! Not using an encryption key for rust crypto store.");
|
||||
}
|
||||
|
||||
// Sessions which still hold a legacy (libolm) crypto store were never migrated to the rust
|
||||
// crypto stack, and we no longer have any way to migrate them. Bail out.
|
||||
if (await StorageManager.hasUnmigratedLegacyCryptoStore()) {
|
||||
throw new LegacyCryptoStoreError();
|
||||
}
|
||||
|
||||
await this.matrixClient.initRustCrypto({
|
||||
storageKey: opts.rustCryptoStoreKey,
|
||||
storagePassword: opts.rustCryptoStorePassword,
|
||||
|
||||
@@ -25,7 +25,9 @@ import { LruCache } from "./utils/LruCache";
|
||||
export type ScreenName = ScreenEvent["$current_url"];
|
||||
export type InteractionName = InteractionEvent["name"];
|
||||
|
||||
const notLoggedInMap: Record<Exclude<Views, Views.LOGGED_IN>, ScreenName> = {
|
||||
// `Views.LEGACY_CRYPTO_UNSUPPORTED` is omitted: the analytics schema has no screen name for it,
|
||||
// and analytics are not initialised for a session we refuse to start anyway.
|
||||
const notLoggedInMap: Record<Exclude<Views, Views.LOGGED_IN | Views.LEGACY_CRYPTO_UNSUPPORTED>, ScreenName> = {
|
||||
[Views.LOADING]: "Loading",
|
||||
[Views.CONFIRM_LOCK_THEFT]: "ConfirmStartup",
|
||||
[Views.WELCOME]: "Welcome",
|
||||
@@ -75,6 +77,8 @@ export default class PosthogTrackers {
|
||||
}
|
||||
|
||||
private trackPage(durationMs?: number): void {
|
||||
if (this.view === Views.LEGACY_CRYPTO_UNSUPPORTED) return;
|
||||
|
||||
const screenName =
|
||||
this.view === Views.LOGGED_IN ? loggedInPageTypeMap[this.pageType!] : notLoggedInMap[this.view];
|
||||
PosthogAnalytics.instance.trackEvent<ScreenEvent>({
|
||||
|
||||
@@ -85,6 +85,15 @@ Please see LICENSE files in the repository root for full details.
|
||||
* │ LOCK_STOLEN │
|
||||
* │ │
|
||||
* └─────────────────┘
|
||||
*
|
||||
* (from LOADING)
|
||||
* │
|
||||
* │ Un-migratable legacy crypto store found
|
||||
* ▼
|
||||
* ┌───────────────────────────┐
|
||||
* │ LEGACY_CRYPTO_UNSUPPORTED │
|
||||
* │ │
|
||||
* └───────────────────────────┘
|
||||
*/
|
||||
enum Views {
|
||||
// a special initial state which is only used at startup, while we are
|
||||
@@ -128,6 +137,13 @@ enum Views {
|
||||
|
||||
// Another instance of the application has started up. We just show an error page.
|
||||
LOCK_STOLEN,
|
||||
|
||||
/**
|
||||
* We found a crypto store created by the legacy (libolm) crypto stack which was never migrated
|
||||
* to the rust stack, and which we can no longer migrate. We just show an error page inviting
|
||||
* the user to sign out and back in.
|
||||
*/
|
||||
LEGACY_CRYPTO_UNSUPPORTED,
|
||||
}
|
||||
|
||||
export default Views;
|
||||
|
||||
@@ -62,6 +62,7 @@ import PlatformPeg from "../../PlatformPeg";
|
||||
import EventIndexPeg from "../../indexing/EventIndexPeg";
|
||||
import MediaDeviceHandler from "../../MediaDeviceHandler";
|
||||
import * as Lifecycle from "../../Lifecycle";
|
||||
import { LegacyCryptoStoreError } from "../../utils/LegacyCryptoStoreError.ts";
|
||||
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../BasePlatform";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
@@ -355,6 +356,19 @@ describe("<MatrixChat />", () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show the 'This device cannot be used' screen if the device was never migrated off legacy crypto", async () => {
|
||||
// Starting the client is what runs crypto init; make it fail the way MatrixClientPeg does
|
||||
// for a device which still holds an un-migrated legacy crypto store.
|
||||
vi.spyOn(Lifecycle, "loadSession").mockRejectedValue(new LegacyCryptoStoreError());
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
|
||||
getComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "This device cannot be used" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fire to focus the message composer", async () => {
|
||||
getComponent();
|
||||
defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: "!room:server.org", focusNext: "composer" });
|
||||
|
||||
@@ -30,7 +30,13 @@ import { TooltipProvider } from "@vector-im/compound-web";
|
||||
// what-input helps improve keyboard accessibility
|
||||
import "what-input";
|
||||
import { sanitizeHtml } from "@element-hq/element-web-shared-utils";
|
||||
import { I18nContext, LinkedTextContext, LinkedText, GenericToast } from "@element-hq/web-shared-components";
|
||||
import {
|
||||
I18nContext,
|
||||
LinkedTextContext,
|
||||
LinkedText,
|
||||
GenericToast,
|
||||
LegacyCryptoUnsupportedView,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
@@ -129,6 +135,7 @@ import { Filter } from "../views/dialogs/spotlight/Filter";
|
||||
import { SessionLockStolenView } from "./auth/SessionLockStolenView";
|
||||
import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView";
|
||||
import { LoginSplashView } from "./auth/LoginSplashView";
|
||||
import { LegacyCryptoStoreError } from "../../utils/LegacyCryptoStoreError.ts";
|
||||
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
|
||||
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
|
||||
import { setTheme } from "../../theme";
|
||||
@@ -142,6 +149,7 @@ 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";
|
||||
import { LegacyCryptoUnsupportedViewModel } from "../../viewmodels/crypto/legacyCryptoUnsupportedViewModel.ts";
|
||||
|
||||
const AUTH_SCREENS = ["register", "mobile_register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
|
||||
|
||||
@@ -292,8 +300,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
const initProm = this.initSession();
|
||||
|
||||
initProm.catch((err) => {
|
||||
// TODO: show an error screen, rather than a spinner of doom
|
||||
logger.error("Error initialising Matrix session", err);
|
||||
|
||||
if (err instanceof LegacyCryptoStoreError) {
|
||||
this.setState({ view: Views.LEGACY_CRYPTO_UNSUPPORTED });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2220,13 +2231,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
return <E2eSetup onCancelled={this.onCompleteSecurityE2eSetupFinished} />;
|
||||
case Views.PENDING_CLIENT_START:
|
||||
// we think we are logged in, but are still waiting for the /sync to complete
|
||||
return (
|
||||
<LoginSplashView
|
||||
matrixClient={MatrixClientPeg.safeGet()}
|
||||
onLogoutClick={this.onLogoutClick}
|
||||
syncError={this.state.syncError}
|
||||
/>
|
||||
);
|
||||
return <LoginSplashView onLogoutClick={this.onLogoutClick} syncError={this.state.syncError} />;
|
||||
case Views.LOGGED_IN:
|
||||
// `ready` and `view==LOGGED_IN` may be set before `page_type` (because the
|
||||
// latter is set via the dispatcher). If we don't yet have a `page_type`,
|
||||
@@ -2248,13 +2253,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
);
|
||||
} else {
|
||||
// we think we are logged in, but are still waiting for the /sync to complete
|
||||
return (
|
||||
<LoginSplashView
|
||||
matrixClient={MatrixClientPeg.safeGet()}
|
||||
onLogoutClick={this.onLogoutClick}
|
||||
syncError={this.state.syncError}
|
||||
/>
|
||||
);
|
||||
return <LoginSplashView onLogoutClick={this.onLogoutClick} syncError={this.state.syncError} />;
|
||||
}
|
||||
case Views.WELCOME:
|
||||
return <Welcome {...this.getServerProperties()} />;
|
||||
@@ -2310,6 +2309,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
);
|
||||
case Views.LOCK_STOLEN:
|
||||
return <SessionLockStolenView />;
|
||||
case Views.LEGACY_CRYPTO_UNSUPPORTED:
|
||||
return (
|
||||
<LegacyCryptoUnsupportedView
|
||||
vm={
|
||||
new LegacyCryptoUnsupportedViewModel({
|
||||
dispatcher: dis,
|
||||
brand: SdkConfig.get().brand,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,25 +8,15 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, render, type RenderResult } from "test-utils-rtl";
|
||||
import { vi, describe, it, expect } from "vitest";
|
||||
import { render, type RenderResult } from "test-utils-rtl";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import EventEmitter from "node:events";
|
||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import { LoginSplashView } from "./LoginSplashView";
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
describe("<LoginSplashView />", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
beforeEach(() => {
|
||||
matrixClient = new EventEmitter() as unknown as MatrixClient;
|
||||
});
|
||||
|
||||
function getComponent(props: Partial<ComponentProps<typeof LoginSplashView>> = {}): RenderResult {
|
||||
const defaultProps = {
|
||||
matrixClient,
|
||||
onLogoutClick: () => {},
|
||||
syncError: null,
|
||||
};
|
||||
@@ -51,20 +41,4 @@ describe("<LoginSplashView />", () => {
|
||||
rendered.getByRole("button", { name: "Logout" }).click();
|
||||
expect(onLogoutClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Shows migration progress", async () => {
|
||||
const rendered = getComponent();
|
||||
|
||||
act(() => {
|
||||
matrixClient.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, 5, 10);
|
||||
});
|
||||
rendered.getByText("Hang tight.", { exact: false });
|
||||
|
||||
// Wait for the animation to update
|
||||
await act(() => sleep(500));
|
||||
|
||||
const progress = rendered.getByRole("progressbar");
|
||||
expect(progress.getAttribute("value")).toEqual("5");
|
||||
expect(progress.getAttribute("max")).toEqual("10");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,21 +7,13 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { messageForSyncError } from "../../../utils/ErrorUtils";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import ProgressBar from "../../views/elements/ProgressBar";
|
||||
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
interface Props {
|
||||
/** The matrix client which is logging in */
|
||||
matrixClient: MatrixClient;
|
||||
|
||||
/**
|
||||
* A callback function. Will be called if the user clicks the "logout" button on the splash screen.
|
||||
*
|
||||
@@ -35,42 +27,19 @@ interface Props {
|
||||
syncError: Error | null;
|
||||
}
|
||||
|
||||
type MigrationState = {
|
||||
progress: number;
|
||||
totalSteps: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The view that is displayed after we have logged in, before the first /sync is completed.
|
||||
*/
|
||||
export function LoginSplashView(props: Props): JSX.Element {
|
||||
const migrationState = useTypedEventEmitterState(
|
||||
props.matrixClient,
|
||||
CryptoEvent.LegacyCryptoStoreMigrationProgress,
|
||||
(progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }),
|
||||
);
|
||||
let errorBox: JSX.Element | undefined;
|
||||
if (props.syncError) {
|
||||
errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>;
|
||||
}
|
||||
|
||||
// If we are migrating the crypto data, show a progress bar. Otherwise, show a normal spinner.
|
||||
let spinnerOrProgress;
|
||||
if (migrationState.totalSteps !== -1) {
|
||||
spinnerOrProgress = (
|
||||
<div className="mx_LoginSplashView_migrationProgress">
|
||||
<p>{_t("migrating_crypto", { brand: SdkConfig.get().brand })}</p>
|
||||
<ProgressBar value={migrationState.progress} max={migrationState.totalSteps} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
spinnerOrProgress = <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_MatrixChat_splash">
|
||||
{errorBox}
|
||||
{spinnerOrProgress}
|
||||
<Spinner />
|
||||
<div className="mx_LoginSplashView_splashButtons">
|
||||
<AccessibleButton kind="link_inline" onClick={props.onLogoutClick}>
|
||||
{_t("action|logout")}
|
||||
|
||||
@@ -1704,7 +1704,6 @@
|
||||
},
|
||||
"member_list_back_action_label": "Room members",
|
||||
"message_edit_dialog_title": "Message edits",
|
||||
"migrating_crypto": "Hang tight. We are updating %(brand)s to make encryption faster and more reliable.",
|
||||
"mobile_guide": {
|
||||
"toast_accept": "Use app",
|
||||
"toast_description": "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.",
|
||||
|
||||
22
apps/web/src/utils/LegacyCryptoStoreError.ts
Normal file
22
apps/web/src/utils/LegacyCryptoStoreError.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error thrown when we find a crypto store which was created by a version of the application using
|
||||
* the legacy (libolm) crypto stack, and which was never migrated to the Rust crypto stack.
|
||||
*
|
||||
* Thrown by `MatrixClientPeg` when setting up the client's crypto, and rethrown by `Lifecycle.loadSession`. `MatrixChat`
|
||||
* catches it and switches to `Views.LEGACY_CRYPTO_UNSUPPORTED`, which explains the problem to the user.
|
||||
*/
|
||||
export class LegacyCryptoStoreError extends Error {
|
||||
public constructor() {
|
||||
super(
|
||||
"This session was created by a version of the application which used the legacy crypto stack, " +
|
||||
"and cannot be migrated. The user must sign out and sign in again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import "fake-indexeddb/auto";
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import * as StorageManager from "./StorageManager";
|
||||
@@ -42,20 +41,6 @@ describe("StorageManager", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function populateLegacyStore(migrationState: number | undefined) {
|
||||
const db = await createDB(LEGACY_CRYPTO_STORE_NAME, [IndexedDBCryptoStore.STORE_ACCOUNT]);
|
||||
|
||||
if (migrationState) {
|
||||
const transaction = db.transaction([IndexedDBCryptoStore.STORE_ACCOUNT], "readwrite");
|
||||
const store = transaction.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
|
||||
store.put(migrationState, "migrationState");
|
||||
await new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = reject;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
global.structuredClone = (v) => JSON.parse(JSON.stringify(v));
|
||||
});
|
||||
@@ -85,39 +70,60 @@ describe("StorageManager", () => {
|
||||
expect(result.dataInCryptoStore).toBe(true);
|
||||
});
|
||||
|
||||
describe("without rust store", () => {
|
||||
it("should be ok if there is non migrated legacy crypto store", async () => {
|
||||
await populateLegacyStore(undefined);
|
||||
it("should not be ok if a legacy crypto store is the only crypto store", async () => {
|
||||
await createDB(LEGACY_CRYPTO_STORE_NAME);
|
||||
|
||||
const result = await StorageManager.checkConsistency();
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.dataInCryptoStore).toBe(true);
|
||||
});
|
||||
const result = await StorageManager.checkConsistency();
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.dataInCryptoStore).toBe(false);
|
||||
});
|
||||
|
||||
it("should be ok if legacy store in MigrationState `NOT_STARTED`", async () => {
|
||||
await populateLegacyStore(0 /* MigrationState.NOT_STARTED*/);
|
||||
it("should not be healthy if no indexeddb", async () => {
|
||||
indexedDB = {} as IDBFactory;
|
||||
|
||||
const result = await StorageManager.checkConsistency();
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.dataInCryptoStore).toBe(true);
|
||||
});
|
||||
const result = await StorageManager.checkConsistency();
|
||||
expect(result.healthy).toBe(false);
|
||||
|
||||
it("should not be ok if MigrationState greater than `NOT_STARTED`", async () => {
|
||||
await populateLegacyStore(1 /*INITIAL_DATA_MIGRATED*/);
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
});
|
||||
|
||||
const result = await StorageManager.checkConsistency();
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.dataInCryptoStore).toBe(false);
|
||||
});
|
||||
describe("hasUnmigratedLegacyCryptoStore", () => {
|
||||
beforeEach(() => {
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
it("should not be healthy if no indexeddb", async () => {
|
||||
indexedDB = {} as IDBFactory;
|
||||
it("should be false when there are no crypto stores", async () => {
|
||||
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
const result = await StorageManager.checkConsistency();
|
||||
expect(result.healthy).toBe(false);
|
||||
it("should be false when there is only a rust crypto store", async () => {
|
||||
await createDB(RUST_CRYPTO_STORE_NAME);
|
||||
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should be true when there is only a legacy crypto store", async () => {
|
||||
await createDB(LEGACY_CRYPTO_STORE_NAME);
|
||||
|
||||
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should be false when a migrated session has both stores", async () => {
|
||||
await createDB(LEGACY_CRYPTO_STORE_NAME);
|
||||
await createDB(RUST_CRYPTO_STORE_NAME);
|
||||
|
||||
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should be false if indexeddb is inaccessible", async () => {
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
indexedDB = {} as IDBFactory;
|
||||
|
||||
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(false);
|
||||
|
||||
indexedDB = new IDBFactory();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,37 +179,45 @@ async function checkSyncStore(): Promise<StoreCheck> {
|
||||
}
|
||||
|
||||
async function checkCryptoStore(): Promise<StoreCheck> {
|
||||
// check first if there is a rust crypto store
|
||||
try {
|
||||
const rustDbExists = await IndexedDBCryptoStore.exists(getIDBFactory()!, RUST_CRYPTO_STORE_NAME);
|
||||
log(`Rust Crypto store using IndexedDB contains data? ${rustDbExists}`);
|
||||
|
||||
if (rustDbExists) {
|
||||
// There was an existing rust database, so consider it healthy.
|
||||
return { exists: true, healthy: true };
|
||||
} else {
|
||||
// No rust store, so let's check if there is a legacy store not yet migrated.
|
||||
try {
|
||||
const legacyIdbExists = await IndexedDBCryptoStore.existsAndIsNotMigrated(
|
||||
getIDBFactory()!,
|
||||
LEGACY_CRYPTO_STORE_NAME,
|
||||
);
|
||||
log(`Legacy Crypto store using IndexedDB contains non migrated data? ${legacyIdbExists}`);
|
||||
return { exists: legacyIdbExists, healthy: true };
|
||||
} catch (e) {
|
||||
error("Legacy crypto store using IndexedDB inaccessible", e);
|
||||
}
|
||||
|
||||
// No need to check local storage or memory as rust stack doesn't support them.
|
||||
// Given that rust stack requires indexeddb, set healthy to false.
|
||||
return { exists: false, healthy: false };
|
||||
}
|
||||
// No need to check local storage or memory as the rust stack doesn't support them.
|
||||
// A missing store is not in itself unhealthy: that is the normal state before the first
|
||||
// login. `checkConsistency` separately flags the case where we expected data to be there.
|
||||
return { exists: rustDbExists, healthy: true };
|
||||
} catch (e) {
|
||||
error("Rust crypto store using IndexedDB inaccessible", e);
|
||||
return { exists: false, healthy: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this session was created by a version of the application which used the legacy
|
||||
* (libolm) crypto stack, and was never migrated to the Rust crypto stack.
|
||||
*
|
||||
* The rust store is checked first, because a session which *was* migrated successfully still has
|
||||
* the legacy database sitting on disk alongside the rust one.
|
||||
*
|
||||
* @returns true if there is a legacy crypto store and no rust crypto store.
|
||||
*/
|
||||
export async function hasUnmigratedLegacyCryptoStore(): Promise<boolean> {
|
||||
const idb = getIDBFactory();
|
||||
if (!idb) return false;
|
||||
|
||||
try {
|
||||
if (await IndexedDBCryptoStore.exists(idb, RUST_CRYPTO_STORE_NAME)) return false;
|
||||
|
||||
const legacyDbExists = await IndexedDBCryptoStore.exists(idb, LEGACY_CRYPTO_STORE_NAME);
|
||||
log(`Legacy crypto store using IndexedDB contains data? ${legacyDbExists}`);
|
||||
return legacyDbExists;
|
||||
} catch (e) {
|
||||
error("Crypto stores using IndexedDB inaccessible", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether crypto has ever been successfully
|
||||
* initialised on this client.
|
||||
|
||||
@@ -10,11 +10,8 @@ import {
|
||||
type MatrixClient,
|
||||
createClient,
|
||||
type ICreateClientOpts,
|
||||
MemoryCryptoStore,
|
||||
MemoryStore,
|
||||
IndexedDBCryptoStore,
|
||||
IndexedDBStore,
|
||||
LocalStorageCryptoStore,
|
||||
RoomNameType,
|
||||
type RoomNameState,
|
||||
EventTimelineSet,
|
||||
@@ -134,7 +131,6 @@ export function createClientWithCreds(creds: IMatrixClientCreds, oauthClientId?:
|
||||
oauthClientId,
|
||||
userId: creds.userId,
|
||||
deviceId: creds.deviceId,
|
||||
pickleKey: creds.pickleKey,
|
||||
timelineSupport: true,
|
||||
forceTURN: !SettingsStore.getValue("webRtcAllowPeerToPeer"),
|
||||
fallbackICEServerAllowed: !!SettingsStore.getValue("fallbackICEServerAllowed"),
|
||||
@@ -170,10 +166,10 @@ export function createClientWithCreds(creds: IMatrixClientCreds, oauthClientId?:
|
||||
* Create a new matrix client, with the persistent stores set up appropriately
|
||||
* (using localstorage/indexeddb, etc)
|
||||
*
|
||||
* @param {Object} opts options to pass to Matrix.createClient. This will be
|
||||
* extended with `sessionStore` and `store` members.
|
||||
* @param opts - options to pass to Matrix.createClient. This will be
|
||||
* extended with `store` and `useAuthorizationHeader` members.
|
||||
*
|
||||
* @returns {MatrixClient} the newly-created MatrixClient
|
||||
* @returns the newly-created MatrixClient
|
||||
*/
|
||||
export function createMatrixClient(opts: ICreateClientOpts): MatrixClient {
|
||||
const storeOpts: Partial<ICreateClientOpts> = {
|
||||
@@ -191,14 +187,6 @@ export function createMatrixClient(opts: ICreateClientOpts): MatrixClient {
|
||||
storeOpts.store = new MemoryStore({ localStorage });
|
||||
}
|
||||
|
||||
if (indexedDB) {
|
||||
storeOpts.cryptoStore = new IndexedDBCryptoStore(indexedDB, "matrix-js-sdk:crypto");
|
||||
} else if (localStorage) {
|
||||
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
|
||||
} else {
|
||||
storeOpts.cryptoStore = new MemoryCryptoStore();
|
||||
}
|
||||
|
||||
return createClient({
|
||||
...storeOpts,
|
||||
...opts,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { LAST_LEGACY_CRYPTO_VERSION, LegacyCryptoUnsupportedViewModel } from "./legacyCryptoUnsupportedViewModel";
|
||||
import { MatrixDispatcher } from "../../dispatcher/dispatcher";
|
||||
|
||||
describe("LegacyCryptoUnsupportedViewModel", () => {
|
||||
let dispatcher: MatrixDispatcher;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatcher = new MatrixDispatcher();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("exposes the brand and the last supported version in the snapshot", () => {
|
||||
const vm = new LegacyCryptoUnsupportedViewModel({ dispatcher, brand: "Acme Chat" });
|
||||
|
||||
expect(vm.getSnapshot()).toEqual({ brand: "Acme Chat", version: LAST_LEGACY_CRYPTO_VERSION });
|
||||
});
|
||||
|
||||
it("dispatches a logout when the sign out action is invoked", () => {
|
||||
const dispatch = vi.spyOn(dispatcher, "dispatch");
|
||||
const vm = new LegacyCryptoUnsupportedViewModel({ dispatcher, brand: "Element" });
|
||||
|
||||
vm.onSignOutClick();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({ action: "logout" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseViewModel,
|
||||
type LegacyCryptoUnsupportedViewSnapshot,
|
||||
type LegacyCryptoUnsupportedViewModel as LegacyCryptoUnsupportedViewModelInterface,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
|
||||
|
||||
/**
|
||||
* The last release that could still migrate a libolm crypto store. Users on an older session must
|
||||
* downgrade to this version to migrate, or remove the device and sign in again.
|
||||
*/
|
||||
export const LAST_LEGACY_CRYPTO_VERSION = "v1.12.30";
|
||||
|
||||
interface LegacyCryptoUnsupportedViewModelProps {
|
||||
/**
|
||||
* Dispatcher used to trigger the sign out.
|
||||
*/
|
||||
dispatcher: MatrixDispatcher;
|
||||
/**
|
||||
* The brand name for the client.
|
||||
*/
|
||||
brand: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for the screen shown when the session's crypto store predates the current crypto stack
|
||||
* and cannot be migrated. The only action offered is signing out.
|
||||
*/
|
||||
export class LegacyCryptoUnsupportedViewModel
|
||||
extends BaseViewModel<LegacyCryptoUnsupportedViewSnapshot, LegacyCryptoUnsupportedViewModelProps>
|
||||
implements LegacyCryptoUnsupportedViewModelInterface
|
||||
{
|
||||
public constructor(props: LegacyCryptoUnsupportedViewModelProps) {
|
||||
super(props, {
|
||||
brand: props.brand,
|
||||
version: LAST_LEGACY_CRYPTO_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the user out. The crypto store cannot be migrated, so removing the device is the only way forward.
|
||||
*/
|
||||
public onSignOutClick = (): void => {
|
||||
this.props.dispatcher.dispatch({ action: "logout" });
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
LegacyCryptoUnsupportedView,
|
||||
type LegacyCryptoUnsupportedViewActions,
|
||||
type LegacyCryptoUnsupportedViewSnapshot,
|
||||
} from "./LegacyCryptoUnsupportedView";
|
||||
import { useMockedViewModel } from "../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type WrapperProps = LegacyCryptoUnsupportedViewSnapshot & LegacyCryptoUnsupportedViewActions;
|
||||
|
||||
const LegacyCryptoUnsupportedViewWrapperImpl = ({ onSignOutClick, ...snapshotProps }: WrapperProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(snapshotProps, { onSignOutClick });
|
||||
return <LegacyCryptoUnsupportedView vm={vm} />;
|
||||
};
|
||||
const LegacyCryptoUnsupportedViewWrapper = withViewDocs(
|
||||
LegacyCryptoUnsupportedViewWrapperImpl,
|
||||
LegacyCryptoUnsupportedView,
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: "Crypto/LegacyCryptoUnsupportedView",
|
||||
component: LegacyCryptoUnsupportedViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
brand: "Element",
|
||||
version: "v1.12.30",
|
||||
onSignOutClick: fn(),
|
||||
},
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
// The view fills its parent, so give it a full-height parent as it has in the app.
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof LegacyCryptoUnsupportedViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, vi, afterEach, expect } from "vitest";
|
||||
|
||||
import * as stories from "./LegacyCryptoUnsupportedView.stories";
|
||||
import {
|
||||
LegacyCryptoUnsupportedView,
|
||||
type LegacyCryptoUnsupportedViewActions,
|
||||
type LegacyCryptoUnsupportedViewSnapshot,
|
||||
} from "./LegacyCryptoUnsupportedView";
|
||||
import { MockViewModel } from "../../core/viewmodel/MockViewModel";
|
||||
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("LegacyCryptoUnsupportedView", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Storybook snapshots", () => {
|
||||
it("renders the default state", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User interactions", () => {
|
||||
const onSignOutClick = vi.fn();
|
||||
|
||||
class TestViewModel
|
||||
extends MockViewModel<LegacyCryptoUnsupportedViewSnapshot>
|
||||
implements LegacyCryptoUnsupportedViewActions
|
||||
{
|
||||
public onSignOutClick = onSignOutClick;
|
||||
}
|
||||
|
||||
it("should call onSignOutClick when the remove device button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const vm = new TestViewModel({ brand: "Element", version: "v1.12.30" });
|
||||
|
||||
render(<LegacyCryptoUnsupportedView vm={vm} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove this device" }));
|
||||
expect(onSignOutClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 JSX, type MouseEventHandler } from "react";
|
||||
import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||
import SignOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
|
||||
|
||||
import styles from "./LegacyCryptoUnsupportedView.module.css";
|
||||
import { type ViewModel, useViewModel } from "../../core/viewmodel";
|
||||
import { Flex } from "../../core/utils/Flex";
|
||||
import { useI18n } from "../../core/i18n/i18nContext";
|
||||
|
||||
/** Snapshot data for the legacy crypto unsupported screen. */
|
||||
export interface LegacyCryptoUnsupportedViewSnapshot {
|
||||
/** The application brand name, for example "Element". */
|
||||
brand: string;
|
||||
/** The last application version that still supports migrating this session, for example "v1.12.30". */
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** User actions emitted by the legacy crypto unsupported screen. */
|
||||
export interface LegacyCryptoUnsupportedViewActions {
|
||||
/** Invoked when the sign out button is clicked. */
|
||||
onSignOutClick: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/** View model contract for the legacy crypto unsupported screen. */
|
||||
export type LegacyCryptoUnsupportedViewModel = ViewModel<
|
||||
LegacyCryptoUnsupportedViewSnapshot,
|
||||
LegacyCryptoUnsupportedViewActions
|
||||
>;
|
||||
|
||||
interface LegacyCryptoUnsupportedViewProps {
|
||||
/**
|
||||
* The view model for the legacy crypto unsupported screen.
|
||||
*/
|
||||
vm: LegacyCryptoUnsupportedViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A full-screen message shown when the session predates the current crypto stack and cannot be migrated.
|
||||
*
|
||||
* The only way forward is to sign out and back in, so the view offers a sign out button and nothing else.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LegacyCryptoUnsupportedView vm={legacyCryptoUnsupportedViewModel} />
|
||||
* ```
|
||||
*/
|
||||
export function LegacyCryptoUnsupportedView({ vm }: Readonly<LegacyCryptoUnsupportedViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const { brand, version } = useViewModel(vm);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Flex className={styles.body} direction="column" align="center" gap="var(--cpd-space-2x)">
|
||||
<Heading as="h1" weight="semibold" size="md">
|
||||
{_t("error|legacy_crypto_unsupported|title")}
|
||||
</Heading>
|
||||
<Text size="lg">{_t("error|legacy_crypto_unsupported|description", { brand })}</Text>
|
||||
<Text size="lg">
|
||||
{_t("error|legacy_crypto_unsupported|actions", { version }, { b: (t) => <strong>{t}</strong> })}
|
||||
</Text>
|
||||
<Button kind="destructive" Icon={SignOutIcon} onClick={vm.onSignOutClick}>
|
||||
{_t("action|remove_this_device")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LegacyCryptoUnsupportedView > Storybook snapshots > renders the default state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="height: 100vh;"
|
||||
>
|
||||
<div
|
||||
class="LegacyCryptoUnsupportedView-module_container"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex LegacyCryptoUnsupportedView-module_body"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
This device cannot be used
|
||||
</h1>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69"
|
||||
>
|
||||
Element was updated from a very old version and cannot migrate your encryption store to a new format.
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69"
|
||||
>
|
||||
<span>
|
||||
Please either downgrade to
|
||||
<strong>
|
||||
v1.12.30
|
||||
</strong>
|
||||
first or remove this device.
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _destructive_1nw83_110"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 12.031q0-.424.288-.712A.97.97 0 0 1 10 11.03h7.15l-1.875-1.875a.96.96 0 0 1-.3-.7q0-.4.325-.725a.93.93 0 0 1 .712-.287.98.98 0 0 1 .688.287l3.6 3.6q.15.15.212.325.063.175.063.375 0 .201-.062.375a.9.9 0 0 1-.213.325l-3.6 3.6q-.3.3-.712.288a.98.98 0 0 1-.688-.288 1.02 1.02 0 0 1-.312-.712.93.93 0 0 1 .287-.713l1.875-1.875H10a.97.97 0 0 1-.712-.287A.97.97 0 0 1 9 12.03m-6-7q0-.824.588-1.412A1.93 1.93 0 0 1 5 3.03h6q.424 0 .713.288.287.287.287.712t-.287.713A.97.97 0 0 1 11 5.03H5v14h6q.424 0 .713.288.287.287.287.712t-.287.713a.97.97 0 0 1-.713.287H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19.03z"
|
||||
/>
|
||||
</svg>
|
||||
Remove this device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
export type {
|
||||
LegacyCryptoUnsupportedViewModel,
|
||||
LegacyCryptoUnsupportedViewSnapshot,
|
||||
LegacyCryptoUnsupportedViewActions,
|
||||
} from "./LegacyCryptoUnsupportedView";
|
||||
export { LegacyCryptoUnsupportedView } from "./LegacyCryptoUnsupportedView";
|
||||
@@ -27,6 +27,7 @@
|
||||
"play": "Play",
|
||||
"react": "React",
|
||||
"remove": "Remove",
|
||||
"remove_this_device": "Remove this device",
|
||||
"reply": "Reply",
|
||||
"reply_in_thread": "Reply in thread",
|
||||
"retry": "Retry",
|
||||
@@ -94,6 +95,13 @@
|
||||
"cancel_search_label": "Cancel search",
|
||||
"emoji_picker": "Emoji picker"
|
||||
},
|
||||
"error": {
|
||||
"legacy_crypto_unsupported": {
|
||||
"actions": "Please either downgrade to <b>%(version)s</b> first or remove this device.",
|
||||
"description": "%(brand)s was updated from a very old version and cannot migrate your encryption store to a new format.",
|
||||
"title": "This device cannot be used"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"shift": "Shift"
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ export * from "./room/composer/Banner";
|
||||
export * from "./room/composer/UploadButton";
|
||||
export * from "./room/composer/MessageComposerUrlPreview/index.ts";
|
||||
export * from "./crypto/SasEmoji";
|
||||
export * from "./crypto/LegacyCryptoUnsupportedView";
|
||||
export * from "./menus/UserMenu";
|
||||
export * from "./notifications/NotificationBadgeView";
|
||||
export * from "./room/timeline/ReadMarker";
|
||||
|
||||
Reference in New Issue
Block a user