diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ad62bee9cf..7ab63a339f2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -119,8 +119,15 @@ jobs: node-version: "lts/*" cache: "pnpm" - - name: Install Deps + - name: Install Deps (layered) + run: "./scripts/layered.sh" + if: matrix.path != 'packages/shared-components' + env: + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} + + - name: Install Deps (normal) run: "pnpm install" + if: matrix.path == 'packages/shared-components' - name: Cache storybook & vitest uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 013991ce950..830b68dfca1 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -75,7 +75,7 @@ if (process.env.VARIANT_PATH) { console.log(`Using variant configuration from '${process.env.VARIANT_PATH}':`); variant = { ...variant, - ...JSON.parse(fs.readFileSync(`${process.env.VARIANT_PATH}`, "utf8")), + ...JSON.parse(fs.readFileSync(process.env.VARIANT_PATH, "utf8")), }; } else { console.warn(`No VARIANT_PATH specified, using default variant configuration '${DEFAULT_VARIANT}':`); diff --git a/apps/desktop/element.io/nightly/config.json b/apps/desktop/element.io/nightly/config.json index 3cb23b0b5e3..9c0cecfaa52 100644 --- a/apps/desktop/element.io/nightly/config.json +++ b/apps/desktop/element.io/nightly/config.json @@ -51,15 +51,11 @@ "features": { "threadsActivityCentre": true, "feature_spotlight": true, - "feature_group_calls": true, "feature_video_rooms": true, "feature_element_call_video_rooms": true }, "setting_defaults": { "RustCrypto.staged_rollout_percent": 100 }, - "element_call": { - "url": "https://call.element.dev" - }, "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" } diff --git a/apps/desktop/element.io/release/config.json b/apps/desktop/element.io/release/config.json index 9bc948b2b6c..b8aed8743d4 100644 --- a/apps/desktop/element.io/release/config.json +++ b/apps/desktop/element.io/release/config.json @@ -50,10 +50,6 @@ }, "features": { "feature_video_rooms": true, - "feature_group_calls": true, "feature_element_call_video_rooms": true - }, - "element_call": { - "url": "https://call.element.io" } } diff --git a/apps/desktop/hak/matrix-seshat/check.ts b/apps/desktop/hak/matrix-seshat/check.ts index 99a107eb5a4..dba992e6fc8 100644 --- a/apps/desktop/hak/matrix-seshat/check.ts +++ b/apps/desktop/hak/matrix-seshat/check.ts @@ -48,8 +48,9 @@ export default async function (hakEnv: HakEnv, moduleInfo: DependencyInfo): Prom "` " + "or your package manager if not using `rustup`", ); + return; } - fsProm.unlink("tmp").then(resolve); + resolve(fsProm.unlink("tmp")); }, ); rustc.stdin!.write("fn main() {}"); diff --git a/apps/desktop/playwright/e2e/launch/oidc.spec.ts b/apps/desktop/playwright/e2e/launch/oidc.spec.ts index c741217a582..f366dd2e5ef 100644 --- a/apps/desktop/playwright/e2e/launch/oidc.spec.ts +++ b/apps/desktop/playwright/e2e/launch/oidc.spec.ts @@ -9,7 +9,7 @@ import { test, expect } from "../../element-desktop-test.js"; declare global { interface ElectronPlatform { - getOidcCallbackUrl(): URL; + getOAuthCallbackUrl(): URL; } interface Window { @@ -29,7 +29,7 @@ test.describe("OIDC Native", () => { test("should use OIDC callback URL without authority component", async ({ page }) => { await expect( page.evaluate(() => { - return window.mxPlatformPeg.get().getOidcCallbackUrl().toString(); + return window.mxPlatformPeg.get().getOAuthCallbackUrl().toString(); }), ).resolves.toMatch(/io\.element\.(desktop|nightly):\/vector\/webapp\//); }); diff --git a/apps/desktop/scripts/hak/copy.ts b/apps/desktop/scripts/hak/copy.ts index dd266201cfd..167aa8ef0be 100644 --- a/apps/desktop/scripts/hak/copy.ts +++ b/apps/desktop/scripts/hak/copy.ts @@ -27,7 +27,7 @@ export default async function copy(hakEnv: HakEnv, moduleInfo: DependencyInfo): if (moduleInfo.moduleBuildDirs.length > 1) { if (!hakEnv.isMac()) { console.error( - "You asked me to copy multiple targets but I've only been taught " + "how to do that on macOS.", + "You asked me to copy multiple targets but I've only been taught how to do that on macOS.", ); throw new Error("Can't copy multiple targets on this platform"); } diff --git a/apps/desktop/scripts/hak/index.ts b/apps/desktop/scripts/hak/index.ts index ea025826327..205a1bcd774 100644 --- a/apps/desktop/scripts/hak/index.ts +++ b/apps/desktop/scripts/hak/index.ts @@ -132,7 +132,7 @@ async function main(): Promise { for (const mod of modules) { const depInfo = deps[mod]; if (depInfo === undefined) { - console.log("Module " + mod + " not found - is it in hakDependencies " + "in your package.json?"); + console.log(`Module ${mod} not found - is it in hakDependencies in your package.json?`); process.exit(1); } console.log("hak " + cmd + ": " + mod); diff --git a/apps/desktop/src/vectormenu.test.ts b/apps/desktop/src/vectormenu.test.ts index f0d1c15375a..5b3582e7c74 100644 --- a/apps/desktop/src/vectormenu.test.ts +++ b/apps/desktop/src/vectormenu.test.ts @@ -42,12 +42,10 @@ describe("buildMenuTemplate", () => { ({ buildMenuTemplate } = await import("./vectormenu.js")); }); - if (platform === "darwin") { - it("should have an app-named item first", () => { - const menu = buildMenuTemplate(); - expect(menu.items[0].label).toBe("ChatApp"); - }); - } + it.runIf(platform === "darwin")("should have an app-named item first", () => { + const menu = buildMenuTemplate(); + expect(menu.items[0].label).toBe("ChatApp"); + }); it("should include expected `help` menu", () => { const menu = buildMenuTemplate(); @@ -55,8 +53,8 @@ describe("buildMenuTemplate", () => { const helpMenu = menu.items.at(-1)!; expect(helpMenu.label).toBe("common|help"); const helpSubmenu = helpMenu.submenu as unknown as MenuItemConstructorOptions[]; - expect(helpSubmenu[0]!.label).toBe("common|brand_help"); - helpSubmenu[0]!.click!(menu.items.at(-1)!, undefined, new Event("click") as KeyboardEvent); + expect(helpSubmenu[0].label).toBe("common|brand_help"); + helpSubmenu[0].click!(menu.items.at(-1)!, undefined, new Event("click") as KeyboardEvent); expect(shell.openExternal).toHaveBeenCalledWith("https://i.need.help"); }); }); diff --git a/apps/web/@types/webpack-version-file-plugin.d.ts b/apps/web/@types/webpack-version-file-plugin.d.ts index 7869805f830..95432bdb1cd 100644 --- a/apps/web/@types/webpack-version-file-plugin.d.ts +++ b/apps/web/@types/webpack-version-file-plugin.d.ts @@ -13,6 +13,7 @@ declare module "webpack-version-file-plugin" { extras?: Record; } + // oxlint-disable-next-line typescript/no-extraneous-class export default class VersionFilePlugin { public constructor(opts: Opts); } diff --git a/apps/web/config.sample.json b/apps/web/config.sample.json index 54656418bdb..e10b825acf1 100644 --- a/apps/web/config.sample.json +++ b/apps/web/config.sample.json @@ -42,8 +42,9 @@ "preferred_domain": "meet.element.io" }, "element_call": { - "url": "https://call.element.io", - "brand": "Element Call" + "brand": "Element Call", + "disable": false, + "use_exclusively": false }, "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" } diff --git a/apps/web/element.io/app/config.json b/apps/web/element.io/app/config.json index 032712a5d88..c1e6158007d 100644 --- a/apps/web/element.io/app/config.json +++ b/apps/web/element.io/app/config.json @@ -47,10 +47,6 @@ }, "features": { "feature_video_rooms": true, - "feature_group_calls": true, "feature_element_call_video_rooms": true - }, - "element_call": { - "url": "https://call.element.io" } } diff --git a/apps/web/element.io/develop/config.json b/apps/web/element.io/develop/config.json index 2db410355c5..2539a1e8f12 100644 --- a/apps/web/element.io/develop/config.json +++ b/apps/web/element.io/develop/config.json @@ -48,15 +48,11 @@ "features": { "threadsActivityCentre": true, "feature_video_rooms": true, - "feature_group_calls": true, "feature_element_call_video_rooms": true }, "setting_defaults": { "RustCrypto.staged_rollout_percent": 100, "Registration.mobileRegistrationHelper": true }, - "element_call": { - "url": "https://call.element.dev" - }, "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" } diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index dd37e40b0f3..2c302984afe 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -62,6 +62,8 @@ const config: Config = { // Ignore vitest tests "!/src/**/*.test.{ts,tsx}", "!/src/test/**", + // Exclude mocks + "!/src/**/*-{mock,mocks}.{ts,tsx}", ], coverageReporters: ["text-summary", ["lcov", { projectRoot: "../../" }]], prettierPath: null, diff --git a/apps/web/package.json b/apps/web/package.json index 1743fdc920d..1126f22a3fc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -85,7 +85,6 @@ "matrix-widget-api": "^1.16.1", "memoize-one": "^6.0.0", "mime": "^4.0.4", - "oidc-client-ts": "^3.0.1", "opus-recorder": "^8.0.3", "pako": "^3.0.0", "png-chunks-extract": "^1.0.0", diff --git a/apps/web/playwright/e2e/read-receipts/index.ts b/apps/web/playwright/e2e/read-receipts/index.ts index 6ff57897956..ee8cbb0667f 100644 --- a/apps/web/playwright/e2e/read-receipts/index.ts +++ b/apps/web/playwright/e2e/read-receipts/index.ts @@ -609,7 +609,7 @@ class Helpers { const roomListContainer = this.page.getByTestId("room-list"); const roomTiles = roomListContainer.getByRole("option"); for (const [i, room] of rooms.entries()) { - await expect(roomTiles.nth(i)).toHaveAccessibleName(new RegExp(`${room.name}`)); + await expect(roomTiles.nth(i)).toHaveAccessibleName(new RegExp(room.name)); } } } diff --git a/apps/web/playwright/e2e/read-receipts/read-receipts.spec.ts b/apps/web/playwright/e2e/read-receipts/read-receipts.spec.ts index 9e38cd4d716..dbb6fd4d8ea 100644 --- a/apps/web/playwright/e2e/read-receipts/read-receipts.spec.ts +++ b/apps/web/playwright/e2e/read-receipts/read-receipts.spec.ts @@ -120,11 +120,11 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await sendUnthreadedReadReceipt(app, main2); // (So the room has no unreads) - await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + await expect(page.getByLabel(otherRoomName)).toBeVisible(); // And we persuade the app to persist its state to indexeddb by reloading and waiting await page.reload(); - await expect(page.getByLabel(`${selectedRoomName}`)).toBeVisible(); + await expect(page.getByLabel(selectedRoomName)).toBeVisible(); // And we reload again, fetching the persisted state FROM indexeddb await page.reload(); @@ -132,7 +132,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { // Then the room is read, because the persisted state correctly remembers both // receipts. (In #24629, the unthreaded receipt overwrote the main thread one, // meaning that the room still said it had unread messages.) - await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + await expect(page.getByLabel(otherRoomName)).toBeVisible(); await expect(page.getByLabel(`${otherRoomName} Unread messages.`)).not.toBeVisible(); }); @@ -172,7 +172,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await sendThreadedReadReceipt(app, main3); // Then the room has no unreads - await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + await expect(page.getByLabel(otherRoomName)).toBeVisible(); }); test("Recognises unread messages on other thread after receiving a receipt for earlier ones", async ({ @@ -210,7 +210,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await sendThreadedReadReceipt(app, thread1b, main1); // Then the room has no unreads - await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + await expect(page.getByLabel(otherRoomName)).toBeVisible(); await util.goTo({ name: otherRoomName, roomId: otherRoomId }); await util.assertReadThread("Message 1"); }); @@ -235,7 +235,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { // thread. The one in main is read because the unthreaded // receipt is for a later event. The room should therefore be // read, and the thread unread. - await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible(); + await expect(page.getByLabel(otherRoomName)).toBeVisible(); await util.goTo({ name: otherRoomName, roomId: otherRoomId }); await util.assertUnreadThread("Message 1"); }); diff --git a/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts b/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts index dbb83c6a18d..78384197d86 100644 --- a/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts +++ b/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts @@ -92,8 +92,10 @@ test.describe("Account user settings tab", () => { authorization_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}authorize`, token_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}token`, revocation_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}revoke`, + registration_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}register`, response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], + grant_types_supported: ["authorization_code", "refresh_token"], + response_modes_supported: ["query", "fragment"], code_challenge_methods_supported: ["S256"], account_management_uri: EXTERNAL_ACCOUNT_MANAGEMENT_URL, }, diff --git a/apps/web/playwright/e2e/settings/room-settings/room-video-tab.spec.ts b/apps/web/playwright/e2e/settings/room-settings/room-video-tab.spec.ts index feff22a04e0..48f8addc623 100644 --- a/apps/web/playwright/e2e/settings/room-settings/room-video-tab.spec.ts +++ b/apps/web/playwright/e2e/settings/room-settings/room-video-tab.spec.ts @@ -8,8 +8,6 @@ import { type Locator } from "@playwright/test"; import { test, expect } from "../../../element-web-test"; -import { SettingLevel } from "../../../../src/settings/SettingLevel"; - test.describe("Voice & Video room settings tab", () => { const roomName = "Test room"; @@ -22,7 +20,6 @@ test.describe("Voice & Video room settings tab", () => { test.beforeEach(async ({ user, app, page }) => { // Execute client actions before setting, as the setting will force a reload. await app.client.createRoom({ name: roomName }); - await app.settings.setValue("feature_group_calls", null, SettingLevel.DEVICE, true); await app.viewRoomByName(roomName); settings = await app.settings.openRoomSettings("Voice & Video"); }); diff --git a/apps/web/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/apps/web/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts index c7fc97f5e0c..f9e72439692 100644 --- a/apps/web/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts +++ b/apps/web/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -11,6 +11,7 @@ import { rejectToast } from "@element-hq/element-web-playwright-common"; import { expect, test } from "."; import { CommandOrControl } from "../../utils"; import { isDendrite } from "../../../plugins/homeserver/dendrite"; +import { getRoomList, getRoomOptionsMenu } from "../../left-panel/room-list-panel/utils"; test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { test.skip( @@ -23,13 +24,15 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { botCreateOpts: { displayName: "Other User" }, }); + test.beforeEach(async ({ page, app, user }) => { + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); + }); + test( "should have the button correctly aligned and displayed in the space panel when expanded", { tag: "@screenshot" }, async ({ util, page }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - // Open the space panel await util.expandSpacePanel(); // The buttons in the space panel should be aligned when expanded @@ -41,9 +44,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { "should not show indicator when there is no thread", { tag: "@screenshot" }, async ({ room1, util, app, page }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - // No indicator should be shown await util.assertNoTacIndicator(); @@ -62,9 +62,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { app, page, }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - await util.goTo(room1); await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); @@ -80,9 +77,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { app, page, }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - await util.goTo(room1); await util.receiveMessages(room1, [ "Msg1", @@ -104,9 +98,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { "should show the rooms with unread threads", { tag: "@screenshot" }, async ({ room1, room2, util, msg, user, app, page }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - await util.goTo(room2); await util.populateThreads(room1, room2, msg, user); // The indicator should be shown @@ -128,9 +119,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { "should update with a thread is read", { tag: "@screenshot" }, async ({ room1, room2, util, msg, user, app, page }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - await util.goTo(room2); await util.populateThreads(room1, room2, msg, user); @@ -155,9 +143,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { ); test("should order by recency after notification level", async ({ room1, room2, util, msg, user, app, page }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - await util.goTo(room2); await util.populateThreads(room1, room2, msg, user, false); @@ -169,9 +154,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { }); test("should block the Spotlight to open when the TAC is opened", async ({ util, page, app }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - const toggleSpotlight = () => page.keyboard.press(`${CommandOrControl}+k`); // Sanity check @@ -187,9 +169,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { }); test("should have the correct hover state", { tag: "@screenshot" }, async ({ util, page, app }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - await util.hoverTacButton(); await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered.png"); @@ -200,9 +179,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { }); test("should mark all threads as read", { tag: "@screenshot" }, async ({ room1, room2, util, msg, page, app }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); await util.assertNotificationTac(); @@ -223,9 +199,6 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { app, page, }) => { - await rejectToast(page, "Verify this device"); - await rejectToast(page, "Notifications"); - await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); await util.openTac(); @@ -233,4 +206,43 @@ test.describe("Threads Activity Centre", { tag: "@no-firefox" }, () => { await util.assertThreadPanelIsOpened(); }); + + // The target room is named so that A-Z sorting places it at the very bottom of the room list. + test.describe("scrolling the room list when a room is selected", () => { + test.use({ room1Name: "zzz thread room" }); + + test("should scroll the room list to reveal the room clicked in the TAC", async ({ + room1, + util, + msg, + app, + page, + }) => { + // Give the target room an unread thread so it shows up in the TAC. + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertNotificationTac(); + + // Create filler rooms that sort before "zzz …" so A-Z sorting pushes the target below the fold. + for (let i = 0; i < 20; i++) { + await app.client.createRoom({ name: `room ${String(i).padStart(2, "0")}` }); + } + + // Sort A-Z so room positions are deterministic and the target sits at the bottom. + await getRoomOptionsMenu(page).click(); + await page.getByRole("menuitemradio", { name: "A-Z" }).click(); + + const roomList = getRoomList(page); + const targetRow = roomList.getByRole("option", { name: `Open room ${room1.name}` }); + + await expect(roomList.getByRole("option", { name: "Open room room 00" })).toBeVisible(); + await expect(targetRow).not.toBeInViewport(); + + // Clicking the room in the TAC dispatches view_room with show_room_tile… + await util.openTac(); + await util.clickRoomInTac(room1.name); + + // …which scrolls the room list to bring the room's tile into view. + await expect(targetRow).toBeInViewport(); + }); + }); }); diff --git a/apps/web/playwright/e2e/voip/element-call.spec.ts b/apps/web/playwright/e2e/voip/element-call.spec.ts index 4d96b5a14a1..a0103d8e2ee 100644 --- a/apps/web/playwright/e2e/voip/element-call.spec.ts +++ b/apps/web/playwright/e2e/voip/element-call.spec.ts @@ -102,14 +102,6 @@ test.use({ test.describe("Element Call", () => { test.use({ - config: { - element_call: { - use_exclusively: false, - }, - features: { - feature_group_calls: true, - }, - }, displayName: "Alice", botCreateOpts: { autoAcceptInvites: true, diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png index fba6afbc8d9..dbe4ba725cd 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png index d34442de593..dd390886e36 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png b/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png index 64be83b5e2c..9335e8cdb10 100644 Binary files a/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png and b/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-linux.png b/apps/web/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-linux.png index f850033eacb..bcfe1113ee7 100644 Binary files a/apps/web/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-linux.png and b/apps/web/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-world-readable-linux.png b/apps/web/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-world-readable-linux.png index 2b758ad0a2e..29354e204b8 100644 Binary files a/apps/web/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-world-readable-linux.png and b/apps/web/playwright/snapshots/settings/room-settings/room-security-tab.spec.ts/room-security-settings-world-readable-linux.png differ diff --git a/apps/web/res/decoder-ring/datatypes.js b/apps/web/res/decoder-ring/datatypes.js index 07fdcc72e99..1913d0d8e26 100644 --- a/apps/web/res/decoder-ring/datatypes.js +++ b/apps/web/res/decoder-ring/datatypes.js @@ -58,56 +58,3 @@ class Some extends Optional { } } const None = new Optional(); - -class FetchStatus { - constructor(opt = {}) { - this.opt = { at: Date.now(), ...opt }; - } - map(f) { - return this; - } - flatMap(f) { - return this; - } -} -class Success extends FetchStatus { - static of(value) { - return new Success(value); - } - constructor(value, opt) { - super(opt); - this.value = value; - } - map(f) { - return new Success(f(this.value), this.opt); - } - flatMap(f) { - return f(this.value, this.opt); - } - fold({ success }) { - return success instanceof Function ? success(this.value, this.opt) : undefined; - } -} -class Pending extends FetchStatus { - static of(opt) { - return new Pending(opt); - } - constructor(opt) { - super(opt); - } - fold({ pending }) { - return pending instanceof Function ? pending(this.opt) : undefined; - } -} -class FetchError extends FetchStatus { - static of(reason, opt) { - return new FetchError(reason, opt); - } - constructor(reason, opt) { - super(opt); - this.reason = reason; - } - fold({ error }) { - return error instanceof Function ? error(this.reason, this.opt) : undefined; - } -} diff --git a/apps/web/src/@types/commonmark.ts b/apps/web/src/@types/commonmark.ts index 110e85f1875..dde24d02519 100644 --- a/apps/web/src/@types/commonmark.ts +++ b/apps/web/src/@types/commonmark.ts @@ -12,7 +12,7 @@ declare module "commonmark" { export type Attr = [key: string, value: string]; /* eslint-disable @typescript-eslint/naming-convention */ - interface HtmlRenderer { + export interface HtmlRenderer { // As far as @types/commonmark is concerned, these are not public, so add them // https://github.com/commonmark/commonmark.js/blob/master/lib/render/html.js#L272-L296 text: (this: commonmark.HtmlRenderer, node: commonmark.Node) => void; diff --git a/apps/web/src/BasePlatform.ts b/apps/web/src/BasePlatform.ts index 26169607db6..290c4f311d1 100644 --- a/apps/web/src/BasePlatform.ts +++ b/apps/web/src/BasePlatform.ts @@ -15,7 +15,7 @@ import { type Room, type SSOAction, encodeUnpaddedBase64, - type OidcRegistrationClientMetadata, + type OAuthRegistrationRequest, MatrixEventEvent, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -431,42 +431,41 @@ export default abstract class BasePlatform { } /** - * Fallback Client URI to use for OIDC client registration for if one is not specified in config.json + * Fallback Client URI to use for OAuth2 client registration for if one is not specified in config.json */ - public get defaultOidcClientUri(): string { + public get defaultOAuthClientUri(): string { return window.location.origin; } /** - * Metadata to use for dynamic OIDC client registrations + * Metadata to use for dynamic OAuth2 client registrations */ - public async getOidcClientMetadata(): Promise { + public async getOAuthClientMetadata(): Promise { const config = SdkConfig.get(); return { - clientName: config.brand, - clientUri: config.oidc_metadata?.client_uri ?? this.defaultOidcClientUri, - redirectUris: [this.getOidcCallbackUrl().href], - logoUri: config.oidc_metadata?.logo_uri ?? new URL("vector-icons/1024.png", this.baseUrl).href, - applicationType: "web", - contacts: config.oidc_metadata?.contacts, - tosUri: config.oidc_metadata?.tos_uri ?? config.terms_and_conditions_links?.[0]?.url, - policyUri: config.oidc_metadata?.policy_uri ?? config.privacy_policy_url, + client_name: config.brand, + client_uri: config.oidc_metadata?.client_uri ?? this.defaultOAuthClientUri, + redirect_uris: [this.getOAuthCallbackUrl().href], + logo_uri: config.oidc_metadata?.logo_uri ?? new URL("vector-icons/1024.png", this.baseUrl).href, + application_type: "web", + tos_uri: config.oidc_metadata?.tos_uri ?? config.terms_and_conditions_links?.[0]?.url, + policy_uri: config.oidc_metadata?.policy_uri ?? config.privacy_policy_url, }; } /** - * Suffix to append to the `state` parameter of OIDC /auth calls. Will be round-tripped to the callback URI. + * Suffix to append to the `state` parameter of OAuth2 /auth calls. Will be round-tripped to the callback URI. * Currently only required for ElectronPlatform for passing element-desktop-ssoid. */ - public getOidcClientState(): string { + public getOAuthClientState(): string { return ""; } /** - * The URL to return to after a successful OIDC authentication + * The URL to return to after a successful OAuth2 authentication */ - public getOidcCallbackUrl(): URL { - // The redirect URL has to exactly match that registered at the OIDC server, so + public getOAuthCallbackUrl(): URL { + // The redirect URL has to exactly match that registered at the OAuth2 server, so // build it from scratch to avoid leaking ephemeral query params (e.g. `updated`). const url = new URL(window.location.origin + window.location.pathname); // Set no_universal_links=true to prevent the callback being handled by Element X installed on macOS Apple Silicon diff --git a/apps/web/src/HtmlUtils.tsx b/apps/web/src/HtmlUtils.tsx index 9a8d885d254..7630d8550fd 100644 --- a/apps/web/src/HtmlUtils.tsx +++ b/apps/web/src/HtmlUtils.tsx @@ -459,7 +459,7 @@ export function bodyToNode(content: IContent, highlights?: string[], opts: Event // This has to be done after the emojiBody check as to not break big emoji on replies formattedBody = formatEmojis(eventInfo.safeBody, true).join(""); } else { - emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[]; + emojiBodyElements = formatEmojis(eventInfo.strippedBody, false); } } diff --git a/apps/web/src/Lifecycle.test.ts b/apps/web/src/Lifecycle.test.ts index ab0d05154e8..78121c9295c 100644 --- a/apps/web/src/Lifecycle.test.ts +++ b/apps/web/src/Lifecycle.test.ts @@ -11,11 +11,17 @@ Please see LICENSE files in the repository root for full details. import { vi, describe, it, expect, beforeEach, afterEach, type MockedObject } from "vitest"; import { logger } from "matrix-js-sdk/src/logger"; import * as MatrixJs from "matrix-js-sdk/src/matrix"; -import { decodeBase64, encodeUnpaddedBase64 } from "matrix-js-sdk/src/matrix"; +import { decodeBase64, encodeUnpaddedBase64, MatrixClient, OAuth2 } from "matrix-js-sdk/src/matrix"; import * as encryptAESSecretStorageItemModule from "matrix-js-sdk/src/utils/encryptAESSecretStorageItem"; import fetchMock from "@fetch-mock/vitest"; -import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "test-utils"; -import { makeDelegatedAuthConfig } from "test-utils/oidc"; +import { + flushPromises, + getMockClientWithEventEmitter, + mockClientMethodsUser, + mockClientMethodsServer, + mockPlatformPeg, +} from "test-utils"; +import { makeDelegatedAuthMetadata } from "test-utils/auth"; import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog"; import * as Lifecycle from "./Lifecycle"; @@ -23,10 +29,9 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import Modal from "./Modal"; import * as StorageAccess from "./utils/StorageAccess"; import { idbSave } from "./utils/StorageAccess"; -import { OidcClientStore } from "./stores/oidc/OidcClientStore"; import { Action } from "./dispatcher/actions"; import PlatformPeg from "./PlatformPeg"; -import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "./utils/tokens/tokens"; +import { persistTokens } from "./utils/tokens/tokens"; import { encryptPickleKey } from "./utils/tokens/pickling"; import * as StorageManager from "./utils/StorageManager.ts"; import type BasePlatform from "./BasePlatform.ts"; @@ -49,22 +54,19 @@ describe("Lifecycle", () => { mockPlatform = mockPlatformPeg(); mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(), + ...mockClientMethodsServer(), stopClient: vi.fn(), removeAllListeners: vi.fn(), clearStores: vi.fn(), - getAccountData: vi.fn(), getDeviceId: vi.fn().mockReturnValue(deviceId), isVersionSupported: vi.fn().mockResolvedValue(true), getCrypto: vi.fn(), - getClientWellKnown: vi.fn(), - waitForClientWellKnown: vi.fn(), getThirdpartyProtocols: vi.fn(), store: { destroy: vi.fn(), }, getVersions: vi.fn().mockResolvedValue({ versions: ["v1.1"] }), logout: vi.fn().mockResolvedValue(undefined), - getAccessToken: vi.fn(), getRefreshToken: vi.fn(), isInitialSyncComplete: vi.fn(), setGuest: vi.fn(), @@ -78,6 +80,7 @@ describe("Lifecycle", () => { localStorage.clear(); sessionStorage.clear(); + vi.spyOn(MatrixClient.prototype, "getAuthMetadata").mockResolvedValue(makeDelegatedAuthMetadata()); }); afterEach(() => { @@ -119,8 +122,6 @@ describe("Lifecycle", () => { mx_is_url: identityServerUrl, mx_user_id: userId, mx_device_id: deviceId, - mx_oidc_token_issuer: "test-issuer.dummy", - mx_oidc_client_id: "test-client-id", }; const idbStorageSession = { account: { @@ -304,6 +305,7 @@ describe("Lifecycle", () => { describe("with a refresh token", () => { beforeEach(() => { localStorage.setItem("mx_refresh_token", refreshToken); + localStorage.setItem("mx_oidc_client_id", "test-client-id"); for (const key in localStorageSession) { localStorage.setItem(key, localStorageSession[key]); } @@ -334,7 +336,7 @@ describe("Lifecycle", () => { guest: false, pickleKey: undefined, }, - expect.any(Function), + expect.any(OAuth2), ); }); }); @@ -344,6 +346,7 @@ describe("Lifecycle", () => { let pickleKey: string; beforeEach(async () => { + localStorage.setItem("mx_oidc_client_id", "test-client-id"); for (const key in localStorageSession) { localStorage.setItem(key, localStorageSession[key]); } @@ -355,7 +358,7 @@ describe("Lifecycle", () => { // Indicate that we should have a pickle key localStorage.setItem("mx_has_pickle_key", "true"); - await persistAccessTokenInStorage(credentials.accessToken, pickleKey); + await persistTokens(pickleKey, credentials); }); it("should persist credentials", async () => { @@ -415,7 +418,7 @@ describe("Lifecycle", () => { guest: false, pickleKey, }, - undefined, + expect.any(OAuth2), ); expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStoreKey: expect.any(Uint8Array) }); @@ -423,7 +426,7 @@ describe("Lifecycle", () => { describe("with a refresh token", () => { beforeEach(async () => { - await persistRefreshTokenInStorage(refreshToken, pickleKey); + await persistTokens(pickleKey, { ...credentials, refreshToken }); }); it("should persist credentials", async () => { @@ -454,7 +457,7 @@ describe("Lifecycle", () => { guest: false, pickleKey: pickleKey, }, - expect.any(Function), + expect.any(OAuth2), ); }); }); @@ -486,7 +489,7 @@ describe("Lifecycle", () => { // Indicate that we should have a pickle key localStorage.setItem("mx_has_pickle_key", "true"); - await persistAccessTokenInStorage(credentials.accessToken, pickleKey); + await persistTokens(pickleKey, credentials); }); it("should create and start new matrix client with credentials", async () => { @@ -532,7 +535,7 @@ describe("Lifecycle", () => { // Create a pickle key, and store it, encrypted, in IDB. const pickleKey = (await PlatformPeg.get()!.createPickleKey(credentials.userId, credentials.deviceId))!; localStorage.setItem("mx_has_pickle_key", "true"); - await persistAccessTokenInStorage(credentials.accessToken, pickleKey); + await persistTokens(pickleKey, credentials); // Now destroy the pickle key await PlatformPeg.get()!.destroyPickleKey(credentials.userId, credentials.deviceId); @@ -624,7 +627,6 @@ describe("Lifecycle", () => { }); it("should persist a refreshToken when present", async () => { - localStorage.setItem("mx_oidc_token_issuer", "test-issuer.dummy"); localStorage.setItem("mx_oidc_client_id", "test-client-id"); await setLoggedIn({ @@ -777,34 +779,18 @@ describe("Lifecycle", () => { const clientId = "test-client-id"; const issuer = "https://auth.com/"; - const delegatedAuthConfig = makeDelegatedAuthConfig(issuer); - const idToken = - "eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJzdWIiOiIwMUhQUDJGU0JZREU5UDlFTU04REQ3V1pIUiIsImlzcyI6Imh0dHBzOi8vYXV0aC1vaWRjLmxhYi5lbGVtZW50LmRldi8iLCJpYXQiOjE3MTUwNzE5ODUsImF1dGhfdGltZSI6MTcwNzk5MDMxMiwiY19oYXNoIjoidGt5R1RhUjU5aTk3YXoyTU4yMGdidyIsImV4cCI6MTcxNTA3NTU4NSwibm9uY2UiOiJxaXhwM0hFMmVaIiwiYXVkIjoiMDFIWDk0Mlg3QTg3REgxRUs2UDRaNjI4WEciLCJhdF9oYXNoIjoiNFlFUjdPRlVKTmRTeEVHV2hJUDlnZyJ9.HxODneXvSTfWB5Vc4cf7b8GiN2gdwUuTiyVqZuupWske2HkZiJZUt5Lsxg9BW3gz28POkE0Ln17snlkmy02B_AD3DQxKOOxQCzIIARHdfFvZxgGWsMdFcVQZDW7rtXcqgj-SpVaUQ_8acsgxSrz_DF2o0O4tto0PT6wVUiw8KlBmgWTscWPeAWe-39T-8EiQ8Wi16h6oSPcz2NzOQ7eOM_S9fDkOorgcBkRGLl1nrahrPSdWJSGAeruk5mX4YxN714YThFDyEA2t9YmKpjaiSQ2tT-Xkd7tgsZqeirNs2ni9mIiFX3bRX6t2AhUNzA7MaX9ZyizKGa6go3BESO_oDg"; + const delegatedAuthConfig = makeDelegatedAuthMetadata(issuer); beforeEach(() => { - fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig); - fetchMock.get(`${delegatedAuthConfig.issuer}jwks`, { - status: 200, - headers: { - "Content-Type": "application/json", - }, - keys: [], - }); - // set values in local storage as they would be after a successful oidc authentication localStorage.setItem("mx_oidc_client_id", clientId); - localStorage.setItem("mx_oidc_token_issuer", issuer); - localStorage.setItem("mx_oidc_id_token", idToken); }); it("should not try to create a token refresher without a refresh token", async () => { - await setLoggedIn(credentials); + const cli = await setLoggedIn(credentials); // didn't try to initialise token refresher - expect(fetchMock).toHaveFetchedTimes( - 0, - `${delegatedAuthConfig.issuer}.well-known/openid-configuration`, - ); + expect(cli.http.opts.tokenRefreshFunction).toBeUndefined(); }); it("should not try to create a token refresher without a deviceId", async () => { @@ -824,7 +810,6 @@ describe("Lifecycle", () => { }); it("should not try to create a token refresher without an issuer in session storage", async () => { - localStorage.removeItem("mx_oidc_token_issuer"); await expect( setLoggedIn({ ...credentials, @@ -881,20 +866,16 @@ describe("Lifecycle", () => { }); describe("logout()", () => { - let oidcClientStore!: OidcClientStore; const accessToken = "test-access-token"; const refreshToken = "test-refresh-token"; beforeEach(() => { - oidcClientStore = new OidcClientStore(mockClient); - // stub - vi.spyOn(oidcClientStore, "revokeTokens").mockResolvedValue(undefined); - mockClient.getAccessToken.mockReturnValue(accessToken); mockClient.getRefreshToken.mockReturnValue(refreshToken); + vi.spyOn(OAuth2.prototype, "revokeToken").mockResolvedValue(undefined); }); - it("should call logout on the client when oidcClientStore is falsy", async () => { + it("should call logout on the client when oauth is not used", async () => { logout(); await flushPromises(); @@ -902,24 +883,15 @@ describe("Lifecycle", () => { expect(mockClient.logout).toHaveBeenCalledWith(true); }); - it("should call logout on the client when oidcClientStore.isUserAuthenticatedWithOidc is falsy", async () => { - vi.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(false); - logout(oidcClientStore); - - await flushPromises(); - - expect(mockClient.logout).toHaveBeenCalledWith(true); - expect(oidcClientStore.revokeTokens).not.toHaveBeenCalled(); - }); - - it("should revoke tokens when user is authenticated with oidc", async () => { - vi.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(true); - logout(oidcClientStore); + it("should revoke tokens when user is authenticated with oauth2", async () => { + localStorage.setItem("mx_oidc_client_id", "test-client-id"); + logout(); await flushPromises(); expect(mockClient.logout).not.toHaveBeenCalled(); - expect(oidcClientStore.revokeTokens).toHaveBeenCalledWith(accessToken, refreshToken); + expect(OAuth2.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token"); + expect(OAuth2.prototype.revokeToken).toHaveBeenCalledWith(refreshToken, "refresh_token"); }); }); diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index 608545ed6b0..6b2e1e42b70 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -10,13 +10,7 @@ Please see LICENSE files in the repository root for full details. */ import { type ReactNode } from "react"; -import { - createClient, - type MatrixClient, - SSOAction, - type OidcTokenRefresher, - decodeBase64, -} from "matrix-js-sdk/src/matrix"; +import { MatrixClient, OAuth2, createClient, SSOAction, decodeBase64 } from "matrix-js-sdk/src/matrix"; import { type AESEncryptedSecretStoragePayload } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -56,30 +50,23 @@ import { Action } from "./dispatcher/actions"; import { type OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; import { SDKContextClass } from "./contexts/SDKContextClass"; import { messageForLoginError } from "./utils/ErrorUtils"; -import { completeOidcLogin, type CompleteOidcLoginResponse } from "./utils/oidc/authorize"; -import { getOidcErrorMessage } from "./utils/oidc/error"; -import { type OidcClientStore } from "./stores/oidc/OidcClientStore"; -import { - getStoredOidcClientId, - getStoredOidcIdTokenClaims, - getStoredOidcTokenIssuer, - persistOidcAuthenticatedSettings, -} from "./utils/oidc/persistOidcSettings"; +import { completeOAuthLogin, type CompleteOAuthLoginResponse } from "./utils/oauth/authorize"; +import { getOAuthErrorMessage } from "./utils/oauth/error"; +import { getOAuthParams, getStoredOAuthClientId, persistOAuthClientId } from "./utils/oauth/persistOAuthSettings"; import { ACCESS_TOKEN_IV, ACCESS_TOKEN_STORAGE_KEY, HAS_ACCESS_TOKEN_STORAGE_KEY, HAS_REFRESH_TOKEN_STORAGE_KEY, - persistAccessTokenInStorage, - persistRefreshTokenInStorage, + persistTokens, REFRESH_TOKEN_IV, REFRESH_TOKEN_STORAGE_KEY, tryDecryptToken, } from "./utils/tokens/tokens"; -import { TokenRefresher } from "./utils/oidc/TokenRefresher"; import { checkBrowserSupport } from "./SupportedBrowser"; import { type URLParams } from "./vector/url_utils.ts"; import { type OnLoggedInPayload } from "./dispatcher/payloads/OnLoggedInPayload.ts"; +import { filterBoolean } from "./utils/arrays.ts"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -263,7 +250,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean] | [null } /** - * If query string includes OIDC authorization code flow parameters attempt to login using oidc flow + * If query string includes OAuth2 authorization code flow parameters attempt to login using oauth flow * Else, we may be returning from SSO - attempt token login * * @param urlParams the parameters read in at app load time from the url @@ -279,30 +266,24 @@ export async function attemptDelegatedAuthLogin( defaultDeviceDisplayName?: string, fragmentAfterLogin?: string, ): Promise { - if (urlParams.oidc_fragment) { - return attemptOidcNativeLogin(urlParams.oidc_fragment, "fragment"); - } else if (urlParams.oidc_query) { - return attemptOidcNativeLogin(urlParams.oidc_query, "query"); + if (urlParams.oauth2) { + return attemptOAuthLogin(urlParams.oauth2); } return attemptTokenLogin(urlParams["legacy_sso"], defaultDeviceDisplayName, fragmentAfterLogin); } /** - * Attempt to login by completing OIDC authorization code flow - * @param urlParams subset of app-load url parameters relating to oidc auth - * @param responseMode - the response_mode used in the auth request + * Attempt to login by completing OAuth2 authorization code flow + * @param urlParams subset of app-load url parameters relating to oauth auth * @returns Promise that resolves to true when login succeeded, else false */ -async function attemptOidcNativeLogin( - urlParams: NonNullable, - responseMode: "fragment" | "query", -): Promise { - console.log("We have OIDC params - attempting OIDC login"); +async function attemptOAuthLogin(urlParams: NonNullable): Promise { + console.log("We have OAuth2 params - attempting login"); try { - const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } = - await completeOidcLogin(urlParams, responseMode); + const { accessToken, refreshToken, homeserverUrl, identityServerUrl, clientId } = + await completeOAuthLogin(urlParams); await configureFromCompletedOAuthLogin({ accessToken, @@ -310,22 +291,20 @@ async function attemptOidcNativeLogin( homeserverUrl, identityServerUrl, clientId, - issuer, - idToken, }); return true; } catch (error) { - logger.error("Failed to login via OIDC", error); + logger.error("Failed to login via OAuth", error); - onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error)); + onFailedDelegatedAuthLogin(getOAuthErrorMessage(error as Error)); return false; } } /** - * Exchange the given OIDC credentials for {@link IMatrixClientCreds}, additionally persisting them to storage. - * @param creds the credentials from the OIDC flow + * Exchange the given OAuth2 credentials for {@link IMatrixClientCreds}, additionally persisting them to storage. + * @param creds the credentials from the OAuth2 flow */ export async function configureFromCompletedOAuthLogin({ accessToken, @@ -333,9 +312,7 @@ export async function configureFromCompletedOAuthLogin({ homeserverUrl, identityServerUrl, clientId, - issuer, - idToken, -}: Omit): Promise { +}: CompleteOAuthLoginResponse): Promise { const { user_id: userId, device_id: deviceId, @@ -352,10 +329,10 @@ export async function configureFromCompletedOAuthLogin({ isGuest, }; - logger.debug("Logged in via OIDC native flow"); + logger.debug("Logged in via OAuth2 native flow"); await onSuccessfulDelegatedAuthLogin(credentials); // this needs to happen after success handler which clears storages - persistOidcAuthenticatedSettings(clientId, issuer, idToken); + persistOAuthClientId(clientId); return credentials; } @@ -478,7 +455,7 @@ async function loadOrCreatePickleKey(credentials: IMatrixClientCreds): Promise void; /** - * Display a friendly error to the user when token login or OIDC authorization fails + * Display a friendly error to the user when token login or OAuth2 authorization fails * @param description error description * @param tryAgain OPTIONAL function to call on try again button from error dialog */ @@ -730,9 +707,9 @@ async function handleLoadSessionFailure(e: unknown, loadSessionOpts?: ILoadSessi * Also stops the old MatrixClient and clears old credentials/etc out of * storage before starting the new client. * - * This function does not work for OIDC login. + * This function does not work for OAuth2 login. * Storage is cleared early in the process so the required data is lost. - * You must use {@link attemptDelegatedAuthLogin} followed by {@link restoreSessionFromStorage} for OIDC login. + * You must use {@link attemptDelegatedAuthLogin} followed by {@link restoreSessionFromStorage} for OAuth2 login. * * @param {IMatrixClientCreds} credentials The credentials to use * @@ -782,44 +759,6 @@ export async function hydrateSession(credentials: IMatrixClientCreds): Promise { - if (!credentials.refreshToken) { - throw new Error("A refresh token must be supplied in order to create an OIDC token refresher."); - } - // stored token issuer indicates we authenticated via OIDC-native flow - const tokenIssuer = getStoredOidcTokenIssuer(); - if (!tokenIssuer) { - throw new Error("Cannot create an OIDC token refresher as no stored OIDC token issuer was found."); - } - - const idTokenClaims = getStoredOidcIdTokenClaims(); - const redirectUri = PlatformPeg.get()!.getOidcCallbackUrl().href; - const deviceId = credentials.deviceId; - if (!deviceId) { - throw new Error("Expected deviceId in user credentials."); - } - const tokenRefresher = new TokenRefresher( - tokenIssuer, - clientId, - redirectUri, - deviceId, - idTokenClaims!, - credentials.userId, - ); - return tokenRefresher; -} - /** * optionally clears localstorage, persists new credentials * to localstorage, starts the new client. @@ -869,21 +808,14 @@ async function doSetLoggedIn( await abortLogin(); } - let storedClientid; + let auth: OAuth2 | undefined; try { - storedClientid = getStoredOidcClientId(); + auth = await hydrateAuth(credentials); } catch {} - let tokenRefresher; - if (credentials.refreshToken && storedClientid) { - tokenRefresher = await createOidcTokenRefresher(credentials, storedClientid); - } else { - logger.debug("No refresh token was supplied: access token will not be refreshed"); - } - // check the session lock just before creating the new client checkSessionLock(); - MatrixClientPeg.set(createClientWithCreds(credentials, tokenRefresher?.doRefreshAccessToken.bind(tokenRefresher))); + MatrixClientPeg.set(createClientWithCreds(credentials, auth)); const client = MatrixClientPeg.safeGet(); setSentryUser(credentials.userId); @@ -956,8 +888,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise { - if (oidcClientStore?.isUserAuthenticatedWithOidc) { - const accessToken = client.getAccessToken() ?? undefined; - const refreshToken = client.getRefreshToken() ?? undefined; +async function doLogout(client: MatrixClient, oauth: OAuth2 | null): Promise { + if (oauth) { + const accessToken = client.getAccessToken(); + const refreshToken = client.getRefreshToken(); + + await Promise.all( + filterBoolean([ + accessToken ? oauth.revokeToken(accessToken, "access_token") : null, + refreshToken ? oauth.revokeToken(refreshToken, "refresh_token") : null, + ]), + ); - await oidcClientStore.revokeTokens(accessToken, refreshToken); + client.stopClient(); + client.http.abort(); } else { await client.logout(true); } @@ -1003,12 +942,21 @@ async function doLogout(client: MatrixClient, oidcClientStore?: OidcClientStore) /** * Logs the current session out and transitions to the logged-out state - * @param oidcClientStore store instance from SDKContext */ -export function logout(oidcClientStore?: OidcClientStore): void { +export async function logout(): Promise { const client = MatrixClientPeg.get(); if (!client) return; + let oauth: OAuth2 | undefined; + try { + oauth = await hydrateAuth({ + homeserverUrl: client.getHomeserverUrl(), + deviceId: client.getDeviceId()!, + }); + } catch (e) { + console.error("@@", e); + } + PosthogAnalytics.instance.logout(); if (client.isGuest()) { @@ -1022,7 +970,7 @@ export function logout(oidcClientStore?: OidcClientStore): void { _isLoggingOut = true; PlatformPeg.get()?.destroyPickleKey(client.getSafeUserId(), client.getDeviceId() ?? ""); - doLogout(client, oidcClientStore).then(onLoggedOut, (err) => { + doLogout(client, oauth ?? null).then(onLoggedOut, (err) => { // Just throwing an error here is going to be very unhelpful // if you're trying to log out because your server's down and // you want to log into a different server, so just forget the @@ -1269,3 +1217,18 @@ window.mxLoginWithAccessToken = async (hsUrl: string, accessToken: string): Prom false, ); }; + +/** + * Instantiate an OAuth2 instance from storage + * Returned promise will reject if the session or the server are not OAuth2-native. + */ +export async function hydrateAuth( + credentials: Pick, +): Promise { + const storedClientId = getStoredOAuthClientId(); + + const tempClient = new MatrixClient({ baseUrl: credentials.homeserverUrl }); + const authMetadata = await tempClient.getAuthMetadata(); + + return new OAuth2(authMetadata, { ...getOAuthParams(storedClientId), deviceId: credentials.deviceId }); +} diff --git a/apps/web/src/Login.ts b/apps/web/src/Login.ts index 07ba1512f86..388eb3e95ae 100644 --- a/apps/web/src/Login.ts +++ b/apps/web/src/Login.ts @@ -11,43 +11,43 @@ import { createClient, type MatrixClient, type LoginFlow, - DELEGATED_OIDC_COMPATIBILITY, + OAUTH_AWARE_PREFERRED_FLOW_FIELD, type ILoginFlow, type LoginRequest, - type OidcClientConfig, + type ValidatedAuthMetadata, type ISSOFlow, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { type IMatrixClientCreds } from "./utils/createMatrixClient"; import { ModuleRunner } from "./modules/ModuleRunner"; -import { getOidcClientId } from "./utils/oidc/registerClient"; +import { getOAuthClientId } from "./utils/oauth/registerClient"; import { type IConfigOptions } from "./IConfigOptions"; import SdkConfig from "./SdkConfig"; -import { isUserRegistrationSupported } from "./utils/oidc/isUserRegistrationSupported"; +import { isUserRegistrationSupported } from "./utils/oauth/isUserRegistrationSupported"; /** * Login flows supported by this client * LoginFlow type use the client API /login endpoint - * OidcNativeFlow is specific to this client + * OAuthNativeFlow is specific to this client */ -export type ClientLoginFlow = LoginFlow | OidcNativeFlow; +export type ClientLoginFlow = LoginFlow | OAuthNativeFlow; interface ILoginOptions { defaultDeviceDisplayName?: string; /** * Delegated auth config from server's .well-known. * - * If this property is set, we will attempt an OIDC login using the delegated auth settings. - * The caller is responsible for checking that OIDC is enabled in the labs settings. + * If this property is set, we will attempt an OAuth2 login using the delegated auth settings. + * The caller is responsible for checking that OAuth2 is enabled in the labs settings. */ - delegatedAuthentication?: OidcClientConfig; + delegatedAuthentication?: ValidatedAuthMetadata; } export default class Login { private flows: Array = []; private readonly defaultDeviceDisplayName?: string; - private delegatedAuthentication?: OidcClientConfig; + private delegatedAuthentication?: ValidatedAuthMetadata; private tempClient: MatrixClient | null = null; // memoize public constructor( @@ -82,7 +82,7 @@ export default class Login { * Set delegated authentication config, clears tempClient. * @param delegatedAuthentication delegated auth config, from ValidatedServerConfig */ - public setDelegatedAuthentication(delegatedAuthentication?: OidcClientConfig): void { + public setDelegatedAuthentication(delegatedAuthentication?: ValidatedAuthMetadata): void { this.tempClient = null; // clear memoization this.delegatedAuthentication = delegatedAuthentication; } @@ -108,29 +108,29 @@ export default class Login { * @returns Promise that resolves to supported login flows */ public async getFlows(isRegistration?: boolean): Promise> { - // try to use oidc native flow if we have delegated auth config + // try to use oauth2 native flow if we have delegated auth config if (this.delegatedAuthentication) { try { - const oidcFlow = await tryInitOidcNativeFlow( + const oauthFlow = await tryInitOAuthNativeFlow( this.delegatedAuthentication, SdkConfig.get().oidc_static_clients, isRegistration, ); - return [oidcFlow]; + return [oauthFlow]; } catch (error) { - logger.error("Failed to get oidc native flow", error); + logger.error("Failed to get OAuth2 native flow", error); } } - // oidc native flow not supported, continue with matrix login + // OAuth2 native flow not supported, continue with matrix login const client = this.createTemporaryClient(); const { flows }: { flows: LoginFlow[] } = await client.loginFlows(); - // If an m.login.sso flow is present which is also flagged as being for MSC3824 OIDC compatibility then we only + // If an m.login.sso flow is present which is also flagged as being for MSC3824 OAuth compatibility then we only // return that flow as (per MSC3824) it is the only one that the user should be offered to give the best experience - const oidcCompatibilityFlow = flows.find( - (f) => f.type === "m.login.sso" && DELEGATED_OIDC_COMPATIBILITY.findIn(f as ISSOFlow), + const oauthCompatibilityFlow = flows.find( + (f) => f.type === "m.login.sso" && OAUTH_AWARE_PREFERRED_FLOW_FIELD.findIn(f as ISSOFlow), ); - this.flows = oidcCompatibilityFlow ? [oidcCompatibilityFlow] : flows; + this.flows = oauthCompatibilityFlow ? [oauthCompatibilityFlow] : flows; return this.flows; } @@ -199,42 +199,42 @@ export default class Login { } /** - * Describes the OIDC native login flow + * Describes the OAuth2 native login flow * Separate from js-sdk's `LoginFlow` as this does not use the same /login flow * to which that type belongs. */ -export interface OidcNativeFlow extends ILoginFlow { - type: "oidcNativeFlow"; - // this client's id as registered with the configured OIDC OP +export interface OAuthNativeFlow extends ILoginFlow { + type: "oauthNativeFlow"; + // this client's id as registered with the configured OAuth2 OP clientId: string; } /** - * Prepares an OidcNativeFlow for logging into the server. + * Prepares an OAuthNativeFlow for logging into the server. * * Finds a static clientId for configured issuer, or attempts dynamic registration with the OP, and wraps the * results. * * @param delegatedAuthConfig Auth config from ValidatedServerConfig - * @param staticOidcClientIds static client config from config.json, used during client registration with OP + * @param staticOAuthClientIds static client config from config.json, used during client registration with OP * @param isRegistration true when we are attempting registration - * @returns Promise when oidc native authentication flow is supported and correctly configured + * @returns Promise when oauth native authentication flow is supported and correctly configured * @throws when client can't register with OP, or any unexpected error */ -const tryInitOidcNativeFlow = async ( - delegatedAuthConfig: OidcClientConfig, - staticOidcClientIds?: IConfigOptions["oidc_static_clients"], +const tryInitOAuthNativeFlow = async ( + delegatedAuthConfig: ValidatedAuthMetadata, + staticOAuthClientIds?: IConfigOptions["oidc_static_clients"], isRegistration?: boolean, -): Promise => { +): Promise => { // if registration is not supported, bail before attempting to get the clientId if (isRegistration && !isUserRegistrationSupported(delegatedAuthConfig)) { throw new Error("Registration is not supported by OP"); } - const clientId = await getOidcClientId(delegatedAuthConfig, staticOidcClientIds); + const clientId = await getOAuthClientId(delegatedAuthConfig, staticOAuthClientIds); const flow = { - type: "oidcNativeFlow", + type: "oauthNativeFlow", clientId, - } as OidcNativeFlow; + } as OAuthNativeFlow; return flow; }; diff --git a/apps/web/src/Markdown.test.ts b/apps/web/src/Markdown.test.ts index 68dc00f9baf..507ef943d64 100644 --- a/apps/web/src/Markdown.test.ts +++ b/apps/web/src/Markdown.test.ts @@ -35,7 +35,6 @@ describe("Markdown parser test", () => { ].join("\n"); it("tests that links with markdown empasis in them are getting properly HTML formatted", () => { - /* eslint-disable max-len */ const expectedResult = [ "

Test1:
#_foonetic_xkcd:matrix.org
http://google.com/_thing_
https://matrix.org/_matrix/client/foo/123_
#_foonetic_xkcd:matrix.org

", "

Test1A:
#_foonetic_xkcd:matrix.org
http://google.com/_thing_
https://matrix.org/_matrix/client/foo/123_
#_foonetic_xkcd:matrix.org

", @@ -43,7 +42,6 @@ describe("Markdown parser test", () => { "

Test3:
https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org
https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org

", "", ].join("\n"); - /* eslint-enable max-len */ const md = new Markdown(testString); expect(md.toHTML()).toEqual(expectedResult); }); @@ -69,7 +67,6 @@ describe("Markdown parser test", () => { "", "", ].join("\n"); - /* eslint-disable max-len */ /** * NOTE: I'm not entirely sure if those "<"" and ">" should be visible in here for #_foonetic_xkcd:matrix.org * but it seems to be actually working properly @@ -81,7 +78,6 @@ describe("Markdown parser test", () => { '

Test3:
https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org
https://riot.im/app/#/room/#_foonetic_xkcd:matrix.org

', "", ].join("\n"); - /* eslint-enable max-len */ const md = new Markdown(test); expect(md.toHTML()).toEqual(expectedResult); }); @@ -114,7 +110,6 @@ describe("Markdown parser test", () => { }); it('expects that links with emphasis are "escaped" correctly', () => { - /* eslint-disable max-len */ const testString = [ "http://domain.xyz/foo/bar-_stuff-like-this_-in-it.jpg" + " " + @@ -139,22 +134,18 @@ describe("Markdown parser test", () => { "https://example.com/_test__test2_test3__", "https://example.com/_test__test2", ].join("
"); - /* eslint-enable max-len */ const md = new Markdown(testString); expect(md.toHTML()).toEqual(expectedResult); }); it("expects that the link part will not be accidentally added to ", () => { - /* eslint-disable max-len */ const testString = `https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py`; const expectedResult = "https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py"; - /* eslint-enable max-len */ const md = new Markdown(testString); expect(md.toHTML()).toEqual(expectedResult); }); it("expects that the link part will not be accidentally added to for multiline links", () => { - /* eslint-disable max-len */ const testString = [ "https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py" + " " + @@ -171,7 +162,6 @@ describe("Markdown parser test", () => { " " + "https://github.com/matrix-org/synapse/blob/develop/synapse/module_api/__init__.py", ].join("
"); - /* eslint-enable max-len */ const md = new Markdown(testString); expect(md.toHTML()).toEqual(expectedResult); }); diff --git a/apps/web/src/Notifier.ts b/apps/web/src/Notifier.ts index 5f0ccd18d56..14ad1a03a7c 100644 --- a/apps/web/src/Notifier.ts +++ b/apps/web/src/Notifier.ts @@ -262,7 +262,7 @@ export default class Notifier extends TypedEventEmitter { return Promise.all( diff --git a/apps/web/src/RoomInvite.tsx b/apps/web/src/RoomInvite.tsx index 0b789a5766b..2c3632c8c35 100644 --- a/apps/web/src/RoomInvite.tsx +++ b/apps/web/src/RoomInvite.tsx @@ -117,7 +117,7 @@ export function showAnyInviteErrors( (avatarUrl && mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24)) ?? undefined } - name={name!} + name={name} idName={user?.userId} size="36px" /> diff --git a/apps/web/src/RoomNotifs.ts b/apps/web/src/RoomNotifs.ts index 2db2cf21580..4489dbf6c3b 100644 --- a/apps/web/src/RoomNotifs.ts +++ b/apps/web/src/RoomNotifs.ts @@ -237,7 +237,7 @@ function isRuleRoomMuteRuleForRoomId(roomId: string, rule: IPushRule): boolean { return false; } // isRuleMaybeRoomMuteRule checks this condition exists - const cond = rule.conditions![0]!; + const cond = rule.conditions![0]; return cond.pattern === roomId; } diff --git a/apps/web/src/ScalarMessaging.ts b/apps/web/src/ScalarMessaging.ts index 2c3f0bfc65a..20858b5d1ce 100644 --- a/apps/web/src/ScalarMessaging.ts +++ b/apps/web/src/ScalarMessaging.ts @@ -316,14 +316,14 @@ enum Action { ReadEvents = "read_events", } -function sendResponse(event: MessageEvent, res: any): void { +function sendResponse(event: MessageEvent, res: any): void { const data = objectClone(event.data); data.response = res; // @ts-ignore event.source.postMessage(data, event.origin); } -function sendError(event: MessageEvent, msg: string, nestedError?: Error): void { +function sendError(event: MessageEvent, msg: string, nestedError?: Error): void { logger.error("Action:" + event.data.action + " failed with message: " + msg); const data = objectClone(event.data); data.response = { @@ -338,7 +338,7 @@ function sendError(event: MessageEvent, msg: string, nestedError?: Error): event.source.postMessage(data, event.origin); } -function inviteUser(event: MessageEvent, roomId: string, userId: string): void { +function inviteUser(event: MessageEvent, roomId: string, userId: string): void { logger.log(`Received request to invite ${userId} into room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { @@ -372,7 +372,7 @@ function inviteUser(event: MessageEvent, roomId: string, userId: string): v ); } -function kickUser(event: MessageEvent, roomId: string, userId: string): void { +function kickUser(event: MessageEvent, roomId: string, userId: string): void { logger.log(`Received request to kick ${userId} from room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { @@ -404,7 +404,7 @@ function kickUser(event: MessageEvent, roomId: string, userId: string): voi }); } -function setWidget(event: MessageEvent, roomId: string | null): void { +function setWidget(event: MessageEvent, roomId: string | null): void { const client = MatrixClientPeg.safeGet(); const widgetId = event.data.widget_id; let widgetType = event.data.type; @@ -488,7 +488,7 @@ function setWidget(event: MessageEvent, roomId: string | null): void { } } -function getWidgets(event: MessageEvent, roomId: string | null): void { +function getWidgets(event: MessageEvent, roomId: string | null): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t("widget|error_need_to_be_logged_in")); @@ -514,7 +514,7 @@ function getWidgets(event: MessageEvent, roomId: string | null): void { sendResponse(event, widgetStateEvents); } -async function getRoomEncState(event: MessageEvent, roomId: string): Promise { +async function getRoomEncState(event: MessageEvent, roomId: string): Promise { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t("widget|error_need_to_be_logged_in")); @@ -530,7 +530,7 @@ async function getRoomEncState(event: MessageEvent, roomId: string): Promis sendResponse(event, roomIsEncrypted); } -function setPlumbingState(event: MessageEvent, roomId: string, status: string): void { +function setPlumbingState(event: MessageEvent, roomId: string, status: string): void { if (typeof status !== "string") { throw new Error("Plumbing state status should be a string"); } @@ -552,7 +552,7 @@ function setPlumbingState(event: MessageEvent, roomId: string, status: stri ); } -function setBotOptions(event: MessageEvent, roomId: string, userId: string): void { +function setBotOptions(event: MessageEvent, roomId: string, userId: string): void { logger.log(`Received request to set options for bot ${userId} in room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { @@ -572,7 +572,7 @@ function setBotOptions(event: MessageEvent, roomId: string, userId: string) } async function setBotPower( - event: MessageEvent, + event: MessageEvent, roomId: string, userId: string, level: number, @@ -613,22 +613,22 @@ async function setBotPower( } } -function getMembershipState(event: MessageEvent, roomId: string, userId: string): void { +function getMembershipState(event: MessageEvent, roomId: string, userId: string): void { logger.log(`membership_state of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.member", userId); } -function getJoinRules(event: MessageEvent, roomId: string): void { +function getJoinRules(event: MessageEvent, roomId: string): void { logger.log(`join_rules of ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.join_rules", ""); } -function botOptions(event: MessageEvent, roomId: string, userId: string): void { +function botOptions(event: MessageEvent, roomId: string, userId: string): void { logger.log(`bot_options of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); } -function getMembershipCount(event: MessageEvent, roomId: string): void { +function getMembershipCount(event: MessageEvent, roomId: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t("widget|error_need_to_be_logged_in")); @@ -643,7 +643,7 @@ function getMembershipCount(event: MessageEvent, roomId: string): void { sendResponse(event, count); } -function canSendEvent(event: MessageEvent, roomId: string): void { +function canSendEvent(event: MessageEvent, roomId: string): void { const evType = "" + event.data.event_type; // force stringify const isState = Boolean(event.data.is_state); const client = MatrixClientPeg.get(); @@ -677,7 +677,7 @@ function canSendEvent(event: MessageEvent, roomId: string): void { sendResponse(event, true); } -function returnStateEvent(event: MessageEvent, roomId: string, eventType: string, stateKey: string): void { +function returnStateEvent(event: MessageEvent, roomId: string, eventType: string, stateKey: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t("widget|error_need_to_be_logged_in")); @@ -696,7 +696,7 @@ function returnStateEvent(event: MessageEvent, roomId: string, eventType: s sendResponse(event, stateEvent.getContent()); } -async function getOpenIdToken(event: MessageEvent): Promise { +async function getOpenIdToken(event: MessageEvent): Promise { try { const tokenObject = await MatrixClientPeg.safeGet().getOpenIdToken(); sendResponse(event, tokenObject); @@ -841,7 +841,7 @@ async function readEvents( } } -const onMessage = function (event: MessageEvent): void { +const onMessage = function (event: MessageEvent): void { if (!event.origin) { // @ts-ignore - stupid chrome event.origin = event.originalEvent.origin; @@ -987,7 +987,7 @@ export function stopListening(): void { } if (listenerCount < 0) { // Make an error so we get a stack trace - const e = new Error("ScalarMessaging: mismatched startListening / stopListening detected." + " Negative count"); + const e = new Error("ScalarMessaging: mismatched startListening / stopListening detected. Negative count"); logger.error(e); } } diff --git a/apps/web/src/SdkConfig.ts b/apps/web/src/SdkConfig.ts index 76e4d47b398..d10e94550a3 100644 --- a/apps/web/src/SdkConfig.ts +++ b/apps/web/src/SdkConfig.ts @@ -35,7 +35,6 @@ export const DEFAULTS = { preferred_domain: "meet.element.io", }, element_call: { - use_exclusively: false, brand: "Element Call", }, @@ -95,6 +94,7 @@ type ObjectType = IConfigOptions[K] extends obje ? SnakedObject> : SnakedObject> | null | undefined; +// oxlint-disable-next-line typescript/no-extraneous-class export default class SdkConfig { private static instance: DeepReadonly; private static fallback: SnakedObject>; diff --git a/apps/web/src/TextForEvent.tsx b/apps/web/src/TextForEvent.tsx index 77d476e0b08..41d0abf8f14 100644 --- a/apps/web/src/TextForEvent.tsx +++ b/apps/web/src/TextForEvent.tsx @@ -49,7 +49,7 @@ function getRoomMemberDisplayname(client: MatrixClient, event: MatrixEvent, user } function textForCallEvent(event: MatrixEvent, client: MatrixClient): () => string { - const roomName = client.getRoom(event.getRoomId()!)?.name; + const roomName = client.getRoom(event.getRoomId())?.name; const isSupported = client.supportsVoip(); return isSupported diff --git a/apps/web/src/accessibility/KeyboardShortcutUtils.ts b/apps/web/src/accessibility/KeyboardShortcutUtils.ts index 2d383d10f68..16c7a5a77a9 100644 --- a/apps/web/src/accessibility/KeyboardShortcutUtils.ts +++ b/apps/web/src/accessibility/KeyboardShortcutUtils.ts @@ -96,7 +96,7 @@ export const getKeyboardShortcuts = (): IKeyboardShortcuts => { return true; }) .reduce((o, key) => { - o[key as KeyBindingAction] = KEYBOARD_SHORTCUTS[key as KeyBindingAction]; + o[key] = KEYBOARD_SHORTCUTS[key]; return o; }, {} as IKeyboardShortcuts); }; diff --git a/apps/web/src/accessibility/LandmarkNavigation.ts b/apps/web/src/accessibility/LandmarkNavigation.ts index acb6812ca3e..9ad30020a63 100644 --- a/apps/web/src/accessibility/LandmarkNavigation.ts +++ b/apps/web/src/accessibility/LandmarkNavigation.ts @@ -32,6 +32,7 @@ const ORDERED_LANDMARKS = [ * The landmarks are cycled through in the following order: * ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> MESSAGE_COMPOSER/HOME <-> ACTIVE_SPACE_BUTTON */ +// oxlint-disable-next-line typescript/no-extraneous-class export class LandmarkNavigation { /** * Get the next/previous landmark that must be focused from a given landmark diff --git a/apps/web/test/unit-tests/actions/RoomListActions-test.ts b/apps/web/src/actions/RoomListActions.test.ts similarity index 89% rename from apps/web/test/unit-tests/actions/RoomListActions-test.ts rename to apps/web/src/actions/RoomListActions.test.ts index ecdf6f676d3..6d47737b8e3 100644 --- a/apps/web/test/unit-tests/actions/RoomListActions-test.ts +++ b/apps/web/src/actions/RoomListActions.test.ts @@ -5,33 +5,35 @@ * Please see LICENSE files in the repository root for full details. */ +// @vitest-environment happy-dom + +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; -import { mocked } from "jest-mock"; +import { createTestClient, flushPromises, mkRoom } from "test-utils"; -import RoomListActions from "../../../src/actions/RoomListActions"; -import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; -import Modal from "../../../src/Modal"; -import * as Rooms from "../../../src/Rooms"; -import { createTestClient, flushPromises, mkRoom } from "../../test-utils"; +import RoomListActions from "./RoomListActions"; +import { DefaultTagID } from "../stores/room-list-v3/skip-list/tag"; +import Modal from "../Modal"; +import * as Rooms from "../Rooms"; -jest.mock("../../../src/Modal"); -jest.mock("../../../src/Rooms"); +vi.mock("../Modal"); +vi.mock("../Rooms"); describe("RoomListActions", () => { const ROOM_ID = "!room:example.org"; let client: MatrixClient; let room: Room; - const dispatch = jest.fn(); + const dispatch = vi.fn(); beforeEach(() => { client = createTestClient(); room = mkRoom(client, ROOM_ID); - mocked(Rooms.guessAndSetDMRoom).mockResolvedValue(undefined); + vi.mocked(Rooms.guessAndSetDMRoom).mockResolvedValue(undefined); }); afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe("tagRoom", () => { @@ -81,7 +83,7 @@ describe("RoomListActions", () => { it("opens an ErrorDialog and swallows the error if guessAndSetDMRoom rejects", async () => { const error = new Error("DM tag error"); - mocked(Rooms.guessAndSetDMRoom).mockRejectedValue(error); + vi.mocked(Rooms.guessAndSetDMRoom).mockRejectedValue(error); await invokeTagRoom(undefined, DefaultTagID.DM); @@ -140,7 +142,7 @@ describe("RoomListActions", () => { it("shows an ErrorDialog but still dispatches success when deleteRoomTag fails", async () => { const error = new Error("delete failed"); - jest.spyOn(client, "deleteRoomTag").mockRejectedValue(error); + vi.spyOn(client, "deleteRoomTag").mockRejectedValue(error); await invokeTagRoom(DefaultTagID.Favourite, DefaultTagID.LowPriority); @@ -156,7 +158,7 @@ describe("RoomListActions", () => { it("shows an ErrorDialog and dispatches failure when setRoomTag fails", async () => { const error = new Error("set failed"); - jest.spyOn(client, "setRoomTag").mockRejectedValue(error); + vi.spyOn(client, "setRoomTag").mockRejectedValue(error); await invokeTagRoom(DefaultTagID.Favourite, DefaultTagID.LowPriority); diff --git a/apps/web/src/actions/RoomListActions.ts b/apps/web/src/actions/RoomListActions.ts index b91be930421..a63b71a6c5b 100644 --- a/apps/web/src/actions/RoomListActions.ts +++ b/apps/web/src/actions/RoomListActions.ts @@ -18,6 +18,7 @@ import { type AsyncActionPayload } from "../dispatcher/payloads"; import { DefaultTagID, type TagID } from "../stores/room-list-v3/skip-list/tag"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; +// oxlint-disable-next-line typescript/no-extraneous-class export default class RoomListActions { /** * Creates an action thunk that will do an asynchronous request to diff --git a/apps/web/src/autocomplete/NotifProvider.tsx b/apps/web/src/autocomplete/NotifProvider.tsx index 2a6e070efa4..57f9496c119 100644 --- a/apps/web/src/autocomplete/NotifProvider.tsx +++ b/apps/web/src/autocomplete/NotifProvider.tsx @@ -40,7 +40,7 @@ export default class NotifProvider extends AutocompleteProvider { if ( command?.[0] && command[0].length > 1 && - ["@room", "@channel", "@everyone", "@here"].some((c) => c.startsWith(command![0])) + ["@room", "@channel", "@everyone", "@here"].some((c) => c.startsWith(command[0])) ) { return [ { diff --git a/apps/web/src/components/structures/GenericDropdownMenu.tsx b/apps/web/src/components/structures/GenericDropdownMenu.tsx index f76e43c1086..2a9f2f00551 100644 --- a/apps/web/src/components/structures/GenericDropdownMenu.tsx +++ b/apps/web/src/components/structures/GenericDropdownMenu.tsx @@ -123,7 +123,7 @@ export function GenericDropdownMenu({ className, AdditionalOptions, }: IProps): JSX.Element { - const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const valueKey = calculateKey(value, toKey); const selected: GenericDropdownMenuItem | undefined = options diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 809d96a14f9..5e43babbc80 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -340,11 +340,7 @@ export default class MatrixChat extends React.PureComponent { ); // remove the loginToken or auth code from the URL regardless - if ( - !!this.props.urlParams.legacy_sso || - !!this.props.urlParams.oidc_fragment || - !!this.props.urlParams.oidc_query - ) { + if (!!this.props.urlParams.legacy_sso || !!this.props.urlParams.oauth2) { this.props.onTokenLoginCompleted(this.props.urlParams, this.getFragmentAfterLogin()); } @@ -696,7 +692,7 @@ export default class MatrixChat extends React.PureComponent { case "logout": this.stores.legacyCallHandler.hangupAllCalls(); Promise.all([...CallStore.instance.connectedCalls].map((call) => call.disconnect())).finally(() => - Lifecycle.logout(this.stores.oidcClientStore), + Lifecycle.logout(), ); break; case "require_registration": @@ -1277,7 +1273,7 @@ export default class MatrixChat extends React.PureComponent { if (isOnlyAdmin(roomToLeave)) { const userLevelValues = roomToLeave.getJoinedMembers().map((m) => m.powerLevel); - const maxUserLevel = Math.max(...(userLevelValues as number[])); + const maxUserLevel = Math.max(...userLevelValues); const warning = maxUserLevel >= 100 diff --git a/apps/web/src/components/structures/PictureInPictureDragger.tsx b/apps/web/src/components/structures/PictureInPictureDragger.tsx index d9f472c3110..914a53f0bbb 100644 --- a/apps/web/src/components/structures/PictureInPictureDragger.tsx +++ b/apps/web/src/components/structures/PictureInPictureDragger.tsx @@ -31,7 +31,7 @@ export type CreatePipChildren = (options: IChildrenOptions) => JSX.Element; interface IChildrenOptions { // a callback which is called when a mouse event (most likely mouse down) occurs at start of moving the pip around - onStartMoving: (event: React.MouseEvent) => void; + onStartMoving: (event: React.MouseEvent) => void; // a callback which is called when the content fo the pip changes in a way that is likely to cause a resize onResize: (event: Event) => void; } diff --git a/apps/web/src/components/structures/RightPanel.tsx b/apps/web/src/components/structures/RightPanel.tsx index 24c16554554..00cdebe5b52 100644 --- a/apps/web/src/components/structures/RightPanel.tsx +++ b/apps/web/src/components/structures/RightPanel.tsx @@ -256,7 +256,7 @@ export default class RightPanel extends React.Component { { this.setState({ membersLoaded: true }); } } catch (err) { - const errorMessage = - `Fetching room members for ${room.roomId} failed.` + " Room members will appear incomplete."; + const errorMessage = `Fetching room members for ${room.roomId} failed. Room members will appear incomplete.`; logger.error(errorMessage); logger.error(err); } diff --git a/apps/web/src/components/structures/ScrollPanel.tsx b/apps/web/src/components/structures/ScrollPanel.tsx index 05bf2e5a7aa..da77abe5e67 100644 --- a/apps/web/src/components/structures/ScrollPanel.tsx +++ b/apps/web/src/components/structures/ScrollPanel.tsx @@ -457,7 +457,7 @@ export default class ScrollPanel extends React.Component { this.unfillDebouncer = window.setTimeout(() => { this.unfillDebouncer = null; debuglog("unfilling now", { backwards, origExcessHeight }); - this.props.onUnfillRequest?.(backwards, markerScrollToken!); + this.props.onUnfillRequest?.(backwards, markerScrollToken); }, UNFILL_REQUEST_DEBOUNCE_MS); } } @@ -666,7 +666,7 @@ export default class ScrollPanel extends React.Component { debuglog("unable to save scroll state: found no children in the viewport"); return; } - const scrollToken = node!.dataset.scrollTokens?.split(",")[0]; + const scrollToken = node.dataset.scrollTokens?.split(",")[0]; debuglog("saving anchored scroll state to message", scrollToken); const bottomOffset = this.topFromBottom(node); this.scrollState = { @@ -788,7 +788,7 @@ export default class ScrollPanel extends React.Component { const m = messages[i] as HTMLElement; // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens // There might only be one scroll token - if (scrollToken && m.dataset.scrollTokens?.split(",").includes(scrollToken!)) { + if (scrollToken && m.dataset.scrollTokens?.split(",").includes(scrollToken)) { node = m; break; } diff --git a/apps/web/src/components/structures/SpaceRoomView.tsx b/apps/web/src/components/structures/SpaceRoomView.tsx index 9bb04ce552e..701a10b3460 100644 --- a/apps/web/src/components/structures/SpaceRoomView.tsx +++ b/apps/web/src/components/structures/SpaceRoomView.tsx @@ -618,7 +618,7 @@ export default class SpaceRoomView extends React.PureComponent { if (showSetup) { phase = - this.props.justCreatedOpts!.createOpts?.preset === Preset.PublicChat + this.props.justCreatedOpts.createOpts?.preset === Preset.PublicChat ? Phase.PublicCreateRooms : Phase.PrivateScope; } diff --git a/apps/web/src/components/structures/ThreadPanel.tsx b/apps/web/src/components/structures/ThreadPanel.tsx index 61386574ea0..7c8a5d0c3d5 100644 --- a/apps/web/src/components/structures/ThreadPanel.tsx +++ b/apps/web/src/components/structures/ThreadPanel.tsx @@ -70,7 +70,7 @@ export const ThreadPanelHeader: React.FC<{ }> = ({ filterOption, setFilterOption }) => { const mxClient = useMatrixClientContext(); const roomContext = useScopedRoomContext("room"); - const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const options: readonly ThreadPanelHeaderOption[] = [ { label: _t("threads|all_threads"), diff --git a/apps/web/src/components/structures/ThreadView.tsx b/apps/web/src/components/structures/ThreadView.tsx index 6c0a04bb4be..3ff168a46c4 100644 --- a/apps/web/src/components/structures/ThreadView.tsx +++ b/apps/web/src/components/structures/ThreadView.tsx @@ -272,7 +272,7 @@ export default class ThreadView extends React.Component { this.timelinePanel.current?.refreshTimeline(this.props.initialEvent?.getId()); } - private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void { + private setupThreadListeners(thread?: Thread, oldThread?: Thread): void { if (oldThread) { this.state.thread?.off(ThreadEvent.NewReply, this.updateThreadRelation); this.props.room.off(RoomEvent.LocalEchoUpdated, this.updateThreadRelation); diff --git a/apps/web/src/components/structures/TimelinePanel.tsx b/apps/web/src/components/structures/TimelinePanel.tsx index 51bce161c7e..392622745ba 100644 --- a/apps/web/src/components/structures/TimelinePanel.tsx +++ b/apps/web/src/components/structures/TimelinePanel.tsx @@ -1532,6 +1532,7 @@ class TimelinePanel extends React.Component { description, }); if (onFinished) { + // oxlint-disable-next-line promise/no-promise-in-callback finished.then(onFinished); } }; diff --git a/apps/web/src/components/structures/UploadBar.tsx b/apps/web/src/components/structures/UploadBar.tsx index 4fbe35f3fdf..b118e197a5d 100644 --- a/apps/web/src/components/structures/UploadBar.tsx +++ b/apps/web/src/components/structures/UploadBar.tsx @@ -63,7 +63,7 @@ export default class UploadBar extends React.PureComponent { public componentWillUnmount(): void { this.unmounted = true; - dis.unregister(this.dispatcherRef!); + dis.unregister(this.dispatcherRef); } private getUploadsInRoom(): RoomUpload[] { diff --git a/apps/web/src/components/structures/auth/ForgotPassword.tsx b/apps/web/src/components/structures/auth/ForgotPassword.tsx index b8a1ead4796..e6de8edb8ba 100644 --- a/apps/web/src/components/structures/auth/ForgotPassword.tsx +++ b/apps/web/src/components/structures/auth/ForgotPassword.tsx @@ -339,7 +339,7 @@ export default class ForgotPassword extends React.Component { homeserver={this.props.serverConfig.hsName} loading={this.state.phase === Phase.SendingEmail} onInputChanged={this.onInputChanged} - onLoginClick={this.props.onLoginClick!} // set by default props + onLoginClick={this.props.onLoginClick} // set by default props onSubmitForm={this.onSubmitForm} /> ); diff --git a/apps/web/src/components/structures/auth/Login.tsx b/apps/web/src/components/structures/auth/Login.tsx index d4cf86cb655..e393e9c996e 100644 --- a/apps/web/src/components/structures/auth/Login.tsx +++ b/apps/web/src/components/structures/auth/Login.tsx @@ -13,7 +13,7 @@ import { type SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix"; import { Button } from "@vector-im/compound-web"; import { _t, UserFriendlyError } from "../../../languageHandler"; -import Login, { type ClientLoginFlow, type OidcNativeFlow } from "../../../Login"; +import Login, { type ClientLoginFlow, type OAuthNativeFlow } from "../../../Login"; import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AuthPage from "../../views/auth/AuthPage"; @@ -31,7 +31,7 @@ import AuthHeader from "../../views/auth/AuthHeader"; import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { filterBoolean } from "../../../utils/arrays"; -import { startOidcLogin } from "../../../utils/oidc/authorize"; +import { startOAuthLogin } from "../../../utils/oauth/authorize"; import { ModuleApi } from "../../../modules/Api.ts"; interface IProps { @@ -122,7 +122,7 @@ class LoginComponent extends React.PureComponent { "m.login.cas": () => this.renderSsoStep("cas"), // eslint-disable-next-line @typescript-eslint/naming-convention "m.login.sso": () => this.renderSsoStep("sso"), - "oidcNativeFlow": () => this.renderOidcNativeStep(), + "oauthNativeFlow": () => this.renderOAuth2Step(), }; } @@ -402,7 +402,7 @@ class LoginComponent extends React.PureComponent { if (!this.state.flows) return null; // this is the ideal order we want to show the flows in - const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"]; + const order = ["oauthNativeFlow", "m.login.password", "m.login.sso"]; const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type))); return ( @@ -434,15 +434,15 @@ class LoginComponent extends React.PureComponent { ); }; - private renderOidcNativeStep = (): React.ReactNode => { - const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow; + private renderOAuth2Step = (): React.ReactNode => { + const flow = this.state.flows!.find((flow) => flow.type === "oauthNativeFlow")! as OAuthNativeFlow; return (