From 0905ed6eccad12acca6fd26cc8cfe464e51450c8 Mon Sep 17 00:00:00 2001 From: Mikhail Aheichyk Date: Mon, 20 Feb 2023 19:31:29 +0300 Subject: [PATCH] Using module api to customize widget permissions Signed-off-by: Mikhail Aheichyk --- cypress/e2e/widgets/stickers.spec.ts | 1 + package.json | 2 +- .../views/context_menus/WidgetContextMenu.tsx | 40 ++++--- src/components/views/elements/AppTile.tsx | 7 +- .../views/right_panel/RoomSummaryCard.tsx | 2 +- .../views/right_panel/WidgetCard.tsx | 2 +- src/stores/widgets/StopGapWidgetDriver.ts | 36 +++++-- .../context_menus/WidgetContextMenu-test.tsx | 101 ++++++++++++++++++ .../views/elements/AppTile-test.tsx | 23 ++++ .../widgets/StopGapWidgetDriver-test.ts | 55 +++++++++- yarn.lock | 8 +- 11 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 test/components/views/context_menus/WidgetContextMenu-test.tsx diff --git a/cypress/e2e/widgets/stickers.spec.ts b/cypress/e2e/widgets/stickers.spec.ts index 5c016b406a9..27986af10e7 100644 --- a/cypress/e2e/widgets/stickers.spec.ts +++ b/cypress/e2e/widgets/stickers.spec.ts @@ -133,6 +133,7 @@ describe("Stickers", () => { type: "m.stickerpicker", name: STICKER_PICKER_WIDGET_NAME, url: stickerPickerUrl, + creatorUserId: "@userId", }, id: STICKER_PICKER_WIDGET_ID, }, diff --git a/package.json b/package.json index a016a9b337a..9ad5904bab4 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.4.0", "@matrix-org/matrix-wysiwyg": "^1.1.1", - "@matrix-org/react-sdk-module-api": "^0.0.3", + "@matrix-org/react-sdk-module-api": "^0.0.4", "@sentry/browser": "^7.0.0", "@sentry/tracing": "^7.0.0", "@testing-library/react-hooks": "^8.0.1", diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index bef5cdc8bf1..b423ab9c750 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -17,6 +17,7 @@ limitations under the License. import React, { useContext } from "react"; import { MatrixCapabilities } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; +import { ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; import { ChevronFace } from "../../structures/ContextMenu"; @@ -34,8 +35,10 @@ import { WidgetType } from "../../../widgets/WidgetType"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream"; +import { ModuleRunner } from "../../../modules/ModuleRunner"; +import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; -interface IProps extends React.ComponentProps { +export interface WidgetContextMenuProps extends React.ComponentProps { app: IApp; userWidget?: boolean; showUnpin?: boolean; @@ -45,7 +48,7 @@ interface IProps extends React.ComponentProps { onEditClick?(): void; } -const WidgetContextMenu: React.FC = ({ +export const WidgetContextMenu: React.FC = ({ onFinished, app, userWidget, @@ -158,24 +161,31 @@ const WidgetContextMenu: React.FC = ({ const isLocalWidget = WidgetType.JITSI.matches(app.type); let revokeButton; if (!userWidget && !isLocalWidget && isAllowedWidget) { - const onRevokeClick = (): void => { - logger.info("Revoking permission for widget to load: " + app.eventId); - const current = SettingsStore.getValue("allowedWidgets", roomId); - if (app.eventId !== undefined) current[app.eventId] = false; - const level = SettingsStore.firstSupportedLevel("allowedWidgets"); - SettingsStore.setValue("allowedWidgets", roomId, level, current).catch((err) => { - logger.error(err); - // We don't really need to do anything about this - the user will just hit the button again. - }); - onFinished(); - }; + const opts: ApprovalOpts = { approved: undefined }; + ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(app)); + + if (!opts.approved) { + const onRevokeClick = (): void => { + logger.info("Revoking permission for widget to load: " + app.eventId); + const current = SettingsStore.getValue("allowedWidgets", roomId); + if (app.eventId !== undefined) current[app.eventId] = false; + const level = SettingsStore.firstSupportedLevel("allowedWidgets"); + if (!level) throw new Error("level must be defined"); + SettingsStore.setValue("allowedWidgets", roomId ?? null, level, current).catch((err) => { + logger.error(err); + // We don't really need to do anything about this - the user will just hit the button again. + }); + onFinished(); + }; - revokeButton = ; + revokeButton = ; + } } let moveLeftButton; if (showUnpin && widgetIndex > 0) { const onClick = (): void => { + if (!room) throw new Error("room must be defined"); WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1); onFinished(); }; @@ -207,5 +217,3 @@ const WidgetContextMenu: React.FC = ({ ); }; - -export default WidgetContextMenu; diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 7bb1fe216bd..e6bf53426b8 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -23,6 +23,7 @@ import classNames from "classnames"; import { MatrixCapabilities } from "matrix-widget-api"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; +import { ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; import AccessibleButton from "./AccessibleButton"; import { _t } from "../../../languageHandler"; @@ -36,7 +37,7 @@ import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu"; import PersistedElement, { getPersistKey } from "./PersistedElement"; import { WidgetType } from "../../../widgets/WidgetType"; import { ElementWidget, StopGapWidget } from "../../../stores/widgets/StopGapWidget"; -import WidgetContextMenu from "../context_menus/WidgetContextMenu"; +import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import WidgetAvatar from "../avatars/WidgetAvatar"; import LegacyCallHandler from "../../../LegacyCallHandler"; import { IApp } from "../../../stores/WidgetStore"; @@ -50,6 +51,7 @@ import { Action } from "../../../dispatcher/actions"; import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidgetCapabilities"; import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { ModuleRunner } from "../../../modules/ModuleRunner"; interface IProps { app: IApp; @@ -162,6 +164,9 @@ export default class AppTile extends React.Component { private hasPermissionToLoad = (props: IProps): boolean => { if (this.usingLocalWidget()) return true; if (!props.room) return true; // user widgets always have permissions + const opts: ApprovalOpts = { approved: undefined }; + ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(this.props.app)); + if (opts.approved) return true; const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId); const allowed = props.app.eventId !== undefined && (currentlyAllowedWidgets[props.app.eventId] ?? false); diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 37d9a6f97a6..b184fd7ba52 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -40,7 +40,7 @@ import { E2EStatus } from "../../../utils/ShieldUtils"; import RoomContext from "../../../contexts/RoomContext"; import { UIComponent, UIFeature } from "../../../settings/UIFeature"; import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; -import WidgetContextMenu from "../context_menus/WidgetContextMenu"; +import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import { useRoomMemberCount } from "../../../hooks/useRoomMembers"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import { usePinnedEvents } from "./PinnedMessagesCard"; diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 44c5393638a..0e26ebcb6c8 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -24,7 +24,7 @@ import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; import { useWidgets } from "./RoomSummaryCard"; import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; -import WidgetContextMenu from "../context_menus/WidgetContextMenu"; +import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import UIStore from "../../../stores/UIStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 9c428e2e286..97c93f883f6 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -39,6 +39,11 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { Direction } from "matrix-js-sdk/src/matrix"; +import { + ApprovalOpts, + CapabilitiesOpts, + WidgetLifecycle, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; import SdkConfig, { DEFAULTS } from "../../SdkConfig"; import { iterableDiff, iterableIntersection } from "../../utils/iterables"; @@ -55,6 +60,7 @@ import dis from "../../dispatcher/dispatcher"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { navigateToPermalink } from "../../utils/permalinks/navigator"; import { SdkContextClass } from "../../contexts/SDKContext"; +import { ModuleRunner } from "../../modules/ModuleRunner"; // TODO: Purge this from the universe @@ -171,15 +177,22 @@ export class StopGapWidgetDriver extends WidgetDriver { allowedSoFar.add(cap); missing.delete(cap); }); + + let approved: Set | undefined; if (WidgetPermissionCustomisations.preapproveCapabilities) { - const approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested); - if (approved) { - approved.forEach((cap) => { - allowedSoFar.add(cap); - missing.delete(cap); - }); - } + approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested); + } else { + const opts: CapabilitiesOpts = { approvedCapabilities: undefined }; + ModuleRunner.instance.invoke(WidgetLifecycle.CapabilitiesRequest, opts, this.forWidget, requested); + approved = opts.approvedCapabilities; } + if (approved) { + approved.forEach((cap) => { + allowedSoFar.add(cap); + missing.delete(cap); + }); + } + // TODO: Do something when the widget requests new capabilities not yet asked for let rememberApproved = false; if (missing.size > 0) { @@ -366,6 +379,15 @@ export class StopGapWidgetDriver extends WidgetDriver { } public async askOpenID(observer: SimpleObservable): Promise { + const opts: ApprovalOpts = { approved: undefined }; + ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, opts, this.forWidget); + if (opts.approved) { + return observer.update({ + state: OpenIDRequestState.Allowed, + token: await MatrixClientPeg.get().getOpenIdToken(), + }); + } + const oidcState = SdkContextClass.instance.widgetPermissionStore.getOIDCState( this.forWidget, this.forWidgetKind, diff --git a/test/components/views/context_menus/WidgetContextMenu-test.tsx b/test/components/views/context_menus/WidgetContextMenu-test.tsx new file mode 100644 index 00000000000..d10c6ad7020 --- /dev/null +++ b/test/components/views/context_menus/WidgetContextMenu-test.tsx @@ -0,0 +1,101 @@ +/* +Copyright 2023 Mikhail Aheichyk +Copyright 2023 Nordeck IT + Consulting GmbH. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { screen, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixWidgetType } from "matrix-widget-api"; +import { + ApprovalOpts, + WidgetInfo, + WidgetLifecycle, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; + +import { + WidgetContextMenu, + WidgetContextMenuProps, +} from "../../../../src/components/views/context_menus/WidgetContextMenu"; +import { IApp } from "../../../../src/stores/WidgetStore"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import WidgetUtils from "../../../../src/utils/WidgetUtils"; +import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; +import SettingsStore from "../../../../src/settings/SettingsStore"; + +describe("", () => { + const widgetId = "w1"; + const eventId = "e1"; + const roomId = "r1"; + const userId = "@user-id:server"; + + const app: IApp = { + id: widgetId, + eventId, + roomId, + type: MatrixWidgetType.Custom, + url: "https://example.com", + name: "Example 1", + creatorUserId: userId, + avatar_url: undefined, + }; + + const mockClient = { + getUserId: jest.fn().mockReturnValue(userId), + } as unknown as MatrixClient; + + let onFinished: () => void; + + beforeEach(() => { + onFinished = jest.fn(); + jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function getComponent(props: Partial = {}): JSX.Element { + return ( + + + + ); + } + + it("renders revoke button", async () => { + const { rerender } = render(getComponent()); + + const revokeButton = screen.getByLabelText("Revoke permissions"); + expect(revokeButton).toBeInTheDocument(); + + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => { + if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === widgetId) { + (opts as ApprovalOpts).approved = true; + } + }); + + rerender(getComponent()); + expect(revokeButton).not.toBeInTheDocument(); + }); + + it("revokes permissions", async () => { + render(getComponent()); + await userEvent.click(screen.getByLabelText("Revoke permissions")); + expect(onFinished).toHaveBeenCalled(); + expect(SettingsStore.getValue("allowedWidgets", roomId)[eventId]).toBe(false); + }); +}); diff --git a/test/components/views/elements/AppTile-test.tsx b/test/components/views/elements/AppTile-test.tsx index 5aaead98fa2..99843d76e6e 100644 --- a/test/components/views/elements/AppTile-test.tsx +++ b/test/components/views/elements/AppTile-test.tsx @@ -23,6 +23,11 @@ import { act, render, RenderResult } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { SpiedFunction } from "jest-mock"; +import { + ApprovalOpts, + WidgetInfo, + WidgetLifecycle, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; import RightPanel from "../../../../src/components/structures/RightPanel"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -44,6 +49,7 @@ import AppsDrawer from "../../../../src/components/views/rooms/AppsDrawer"; import { ElementWidgetCapabilities } from "../../../../src/stores/widgets/ElementWidgetCapabilities"; import { ElementWidget } from "../../../../src/stores/widgets/StopGapWidget"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; +import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; describe("AppTile", () => { let cli: MatrixClient; @@ -380,4 +386,21 @@ describe("AppTile", () => { }); }); }); + + it("for a pinned widget permission load", () => { + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => { + if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === app1.id) { + (opts as ApprovalOpts).approved = true; + } + }); + + // userId and creatorUserId are different + const renderResult = render( + + + , + ); + + expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument(); + }); }); diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts index a258fbf52de..855ba17acd2 100644 --- a/test/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/stores/widgets/StopGapWidgetDriver-test.ts @@ -18,12 +18,27 @@ import { mocked, MockedObject } from "jest-mock"; import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client"; import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; import { Direction, EventType, MatrixEvent, MsgType, RelationType } from "matrix-js-sdk/src/matrix"; -import { Widget, MatrixWidgetType, WidgetKind, WidgetDriver, ITurnServer } from "matrix-widget-api"; +import { + Widget, + MatrixWidgetType, + WidgetKind, + WidgetDriver, + ITurnServer, + SimpleObservable, + OpenIDRequestState, + IOpenIDUpdate, +} from "matrix-widget-api"; +import { + ApprovalOpts, + CapabilitiesOpts, + WidgetLifecycle, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { StopGapWidgetDriver } from "../../../src/stores/widgets/StopGapWidgetDriver"; import { stubClient } from "../../test-utils"; +import { ModuleRunner } from "../../../src/modules/ModuleRunner"; import dis from "../../../src/dispatcher/dispatcher"; describe("StopGapWidgetDriver", () => { @@ -101,6 +116,44 @@ describe("StopGapWidgetDriver", () => { expect(approvedCapabilities).toEqual(requestedCapabilities); }); + it("approves capabilities via module api", async () => { + const driver = mkDefaultDriver(); + + const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]); + + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation( + (lifecycleEvent, opts, widgetInfo, requested) => { + if (lifecycleEvent === WidgetLifecycle.CapabilitiesRequest) { + (opts as CapabilitiesOpts).approvedCapabilities = requested; + } + }, + ); + + const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities); + expect(approvedCapabilities).toEqual(requestedCapabilities); + }); + + it("approves identity via module api", async () => { + const driver = mkDefaultDriver(); + + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => { + if (lifecycleEvent === WidgetLifecycle.IdentityRequest) { + (opts as ApprovalOpts).approved = true; + } + }); + + const listener = jest.fn(); + const observer = new SimpleObservable(); + observer.onUpdate(listener); + await driver.askOpenID(observer); + + const openIdUpdate: IOpenIDUpdate = { + state: OpenIDRequestState.Allowed, + token: await client.getOpenIdToken(), + }; + expect(listener).toBeCalledWith(openIdUpdate); + }); + describe("sendToDevice", () => { const contentMap = { "@alice:example.org": { diff --git a/yarn.lock b/yarn.lock index dc7f346b89b..2f15e9fad31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1598,10 +1598,10 @@ version "3.2.14" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz#acd96c00a881d0f462e1f97a56c73742c8dbc984" -"@matrix-org/react-sdk-module-api@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-0.0.3.tgz#a7ac1b18a72d18d08290b81fa33b0d8d00a77d2b" - integrity sha512-jQmLhVIanuX0g7Jx1OIqlzs0kp72PfSpv3umi55qVPYcAPQmO252AUs0vncatK8O4e013vohdnNhly19a/kmLQ== +"@matrix-org/react-sdk-module-api@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-0.0.4.tgz#da71fc2e4c8143e87b5c2bc067ccbc0c146816fe" + integrity sha512-4gcgef3Ne9+Ae0bAErK1Swo9FxTZBDEogX/Iu2kcLWWROOKMjmeWL2PkM83ylsxZ32YY6a6ndRqV/SwRmDeJxg== dependencies: "@babel/runtime" "^7.17.9"