Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Using module api to customize widget permissions
Browse files Browse the repository at this point in the history
Signed-off-by: Mikhail Aheichyk <mikhail.aheichyk@nordeck.net>
  • Loading branch information
Mikhail Aheichyk committed Feb 20, 2023
1 parent 8c22584 commit 0905ed6
Show file tree
Hide file tree
Showing 11 changed files with 245 additions and 32 deletions.
1 change: 1 addition & 0 deletions cypress/e2e/widgets/stickers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ describe("Stickers", () => {
type: "m.stickerpicker",
name: STICKER_PICKER_WIDGET_NAME,
url: stickerPickerUrl,
creatorUserId: "@userId",
},
id: STICKER_PICKER_WIDGET_ID,
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 24 additions & 16 deletions src/components/views/context_menus/WidgetContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<typeof IconizedContextMenu> {
export interface WidgetContextMenuProps extends React.ComponentProps<typeof IconizedContextMenu> {
app: IApp;
userWidget?: boolean;
showUnpin?: boolean;
Expand All @@ -45,7 +48,7 @@ interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
onEditClick?(): void;
}

const WidgetContextMenu: React.FC<IProps> = ({
export const WidgetContextMenu: React.FC<WidgetContextMenuProps> = ({
onFinished,
app,
userWidget,
Expand Down Expand Up @@ -158,24 +161,31 @@ const WidgetContextMenu: React.FC<IProps> = ({
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 = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
}
}

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();
};
Expand Down Expand Up @@ -207,5 +217,3 @@ const WidgetContextMenu: React.FC<IProps> = ({
</IconizedContextMenu>
);
};

export default WidgetContextMenu;
7 changes: 6 additions & 1 deletion src/components/views/elements/AppTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -162,6 +164,9 @@ export default class AppTile extends React.Component<IProps, IState> {
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);
Expand Down
2 changes: 1 addition & 1 deletion src/components/views/right_panel/RoomSummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/components/views/right_panel/WidgetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
36 changes: 29 additions & 7 deletions src/stores/widgets/StopGapWidgetDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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

Expand Down Expand Up @@ -171,15 +177,22 @@ export class StopGapWidgetDriver extends WidgetDriver {
allowedSoFar.add(cap);
missing.delete(cap);
});

let approved: Set<string> | 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) {
Expand Down Expand Up @@ -366,6 +379,15 @@ export class StopGapWidgetDriver extends WidgetDriver {
}

public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
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,
Expand Down
101 changes: 101 additions & 0 deletions test/components/views/context_menus/WidgetContextMenu-test.tsx
Original file line number Diff line number Diff line change
@@ -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("<WidgetContextMenu />", () => {
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<WidgetContextMenuProps> = {}): JSX.Element {
return (
<MatrixClientContext.Provider value={mockClient}>
<WidgetContextMenu app={app} onFinished={onFinished} {...props} />
</MatrixClientContext.Provider>
);
}

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);
});
});
23 changes: 23 additions & 0 deletions test/components/views/elements/AppTile-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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(
<MatrixClientContext.Provider value={cli}>
<AppTile key={app1.id} app={app1} room={r1} userId="@user1" creatorUserId="@userAnother" />
</MatrixClientContext.Provider>,
);

expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
});
});
Loading

0 comments on commit 0905ed6

Please sign in to comment.