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

Commit 0905ed6

Browse files
committed
Using module api to customize widget permissions
Signed-off-by: Mikhail Aheichyk <mikhail.aheichyk@nordeck.net>
1 parent 8c22584 commit 0905ed6

File tree

11 files changed

+245
-32
lines changed

11 files changed

+245
-32
lines changed

cypress/e2e/widgets/stickers.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ describe("Stickers", () => {
133133
type: "m.stickerpicker",
134134
name: STICKER_PICKER_WIDGET_NAME,
135135
url: stickerPickerUrl,
136+
creatorUserId: "@userId",
136137
},
137138
id: STICKER_PICKER_WIDGET_ID,
138139
},

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"@babel/runtime": "^7.12.5",
5959
"@matrix-org/analytics-events": "^0.4.0",
6060
"@matrix-org/matrix-wysiwyg": "^1.1.1",
61-
"@matrix-org/react-sdk-module-api": "^0.0.3",
61+
"@matrix-org/react-sdk-module-api": "^0.0.4",
6262
"@sentry/browser": "^7.0.0",
6363
"@sentry/tracing": "^7.0.0",
6464
"@testing-library/react-hooks": "^8.0.1",

src/components/views/context_menus/WidgetContextMenu.tsx

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
import React, { useContext } from "react";
1818
import { MatrixCapabilities } from "matrix-widget-api";
1919
import { logger } from "matrix-js-sdk/src/logger";
20+
import { ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
2021

2122
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
2223
import { ChevronFace } from "../../structures/ContextMenu";
@@ -34,8 +35,10 @@ import { WidgetType } from "../../../widgets/WidgetType";
3435
import MatrixClientContext from "../../../contexts/MatrixClientContext";
3536
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
3637
import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream";
38+
import { ModuleRunner } from "../../../modules/ModuleRunner";
39+
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
3740

38-
interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
41+
export interface WidgetContextMenuProps extends React.ComponentProps<typeof IconizedContextMenu> {
3942
app: IApp;
4043
userWidget?: boolean;
4144
showUnpin?: boolean;
@@ -45,7 +48,7 @@ interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
4548
onEditClick?(): void;
4649
}
4750

48-
const WidgetContextMenu: React.FC<IProps> = ({
51+
export const WidgetContextMenu: React.FC<WidgetContextMenuProps> = ({
4952
onFinished,
5053
app,
5154
userWidget,
@@ -158,24 +161,31 @@ const WidgetContextMenu: React.FC<IProps> = ({
158161
const isLocalWidget = WidgetType.JITSI.matches(app.type);
159162
let revokeButton;
160163
if (!userWidget && !isLocalWidget && isAllowedWidget) {
161-
const onRevokeClick = (): void => {
162-
logger.info("Revoking permission for widget to load: " + app.eventId);
163-
const current = SettingsStore.getValue("allowedWidgets", roomId);
164-
if (app.eventId !== undefined) current[app.eventId] = false;
165-
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
166-
SettingsStore.setValue("allowedWidgets", roomId, level, current).catch((err) => {
167-
logger.error(err);
168-
// We don't really need to do anything about this - the user will just hit the button again.
169-
});
170-
onFinished();
171-
};
164+
const opts: ApprovalOpts = { approved: undefined };
165+
ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(app));
166+
167+
if (!opts.approved) {
168+
const onRevokeClick = (): void => {
169+
logger.info("Revoking permission for widget to load: " + app.eventId);
170+
const current = SettingsStore.getValue("allowedWidgets", roomId);
171+
if (app.eventId !== undefined) current[app.eventId] = false;
172+
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
173+
if (!level) throw new Error("level must be defined");
174+
SettingsStore.setValue("allowedWidgets", roomId ?? null, level, current).catch((err) => {
175+
logger.error(err);
176+
// We don't really need to do anything about this - the user will just hit the button again.
177+
});
178+
onFinished();
179+
};
172180

173-
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
181+
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
182+
}
174183
}
175184

176185
let moveLeftButton;
177186
if (showUnpin && widgetIndex > 0) {
178187
const onClick = (): void => {
188+
if (!room) throw new Error("room must be defined");
179189
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
180190
onFinished();
181191
};
@@ -207,5 +217,3 @@ const WidgetContextMenu: React.FC<IProps> = ({
207217
</IconizedContextMenu>
208218
);
209219
};
210-
211-
export default WidgetContextMenu;

src/components/views/elements/AppTile.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import classNames from "classnames";
2323
import { MatrixCapabilities } from "matrix-widget-api";
2424
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
2525
import { logger } from "matrix-js-sdk/src/logger";
26+
import { ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
2627

2728
import AccessibleButton from "./AccessibleButton";
2829
import { _t } from "../../../languageHandler";
@@ -36,7 +37,7 @@ import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
3637
import PersistedElement, { getPersistKey } from "./PersistedElement";
3738
import { WidgetType } from "../../../widgets/WidgetType";
3839
import { ElementWidget, StopGapWidget } from "../../../stores/widgets/StopGapWidget";
39-
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
40+
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
4041
import WidgetAvatar from "../avatars/WidgetAvatar";
4142
import LegacyCallHandler from "../../../LegacyCallHandler";
4243
import { IApp } from "../../../stores/WidgetStore";
@@ -50,6 +51,7 @@ import { Action } from "../../../dispatcher/actions";
5051
import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidgetCapabilities";
5152
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
5253
import { SdkContextClass } from "../../../contexts/SDKContext";
54+
import { ModuleRunner } from "../../../modules/ModuleRunner";
5355

5456
interface IProps {
5557
app: IApp;
@@ -162,6 +164,9 @@ export default class AppTile extends React.Component<IProps, IState> {
162164
private hasPermissionToLoad = (props: IProps): boolean => {
163165
if (this.usingLocalWidget()) return true;
164166
if (!props.room) return true; // user widgets always have permissions
167+
const opts: ApprovalOpts = { approved: undefined };
168+
ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(this.props.app));
169+
if (opts.approved) return true;
165170

166171
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
167172
const allowed = props.app.eventId !== undefined && (currentlyAllowedWidgets[props.app.eventId] ?? false);

src/components/views/right_panel/RoomSummaryCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { E2EStatus } from "../../../utils/ShieldUtils";
4040
import RoomContext from "../../../contexts/RoomContext";
4141
import { UIComponent, UIFeature } from "../../../settings/UIFeature";
4242
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
43-
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
43+
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
4444
import { useRoomMemberCount } from "../../../hooks/useRoomMembers";
4545
import { useFeatureEnabled } from "../../../hooks/useSettings";
4646
import { usePinnedEvents } from "./PinnedMessagesCard";

src/components/views/right_panel/WidgetCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import AppTile from "../elements/AppTile";
2424
import { _t } from "../../../languageHandler";
2525
import { useWidgets } from "./RoomSummaryCard";
2626
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
27-
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
27+
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
2828
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
2929
import UIStore from "../../../stores/UIStore";
3030
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";

src/stores/widgets/StopGapWidgetDriver.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ import { Room } from "matrix-js-sdk/src/models/room";
3939
import { logger } from "matrix-js-sdk/src/logger";
4040
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
4141
import { Direction } from "matrix-js-sdk/src/matrix";
42+
import {
43+
ApprovalOpts,
44+
CapabilitiesOpts,
45+
WidgetLifecycle,
46+
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
4247

4348
import SdkConfig, { DEFAULTS } from "../../SdkConfig";
4449
import { iterableDiff, iterableIntersection } from "../../utils/iterables";
@@ -55,6 +60,7 @@ import dis from "../../dispatcher/dispatcher";
5560
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
5661
import { navigateToPermalink } from "../../utils/permalinks/navigator";
5762
import { SdkContextClass } from "../../contexts/SDKContext";
63+
import { ModuleRunner } from "../../modules/ModuleRunner";
5864

5965
// TODO: Purge this from the universe
6066

@@ -171,15 +177,22 @@ export class StopGapWidgetDriver extends WidgetDriver {
171177
allowedSoFar.add(cap);
172178
missing.delete(cap);
173179
});
180+
181+
let approved: Set<string> | undefined;
174182
if (WidgetPermissionCustomisations.preapproveCapabilities) {
175-
const approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested);
176-
if (approved) {
177-
approved.forEach((cap) => {
178-
allowedSoFar.add(cap);
179-
missing.delete(cap);
180-
});
181-
}
183+
approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested);
184+
} else {
185+
const opts: CapabilitiesOpts = { approvedCapabilities: undefined };
186+
ModuleRunner.instance.invoke(WidgetLifecycle.CapabilitiesRequest, opts, this.forWidget, requested);
187+
approved = opts.approvedCapabilities;
182188
}
189+
if (approved) {
190+
approved.forEach((cap) => {
191+
allowedSoFar.add(cap);
192+
missing.delete(cap);
193+
});
194+
}
195+
183196
// TODO: Do something when the widget requests new capabilities not yet asked for
184197
let rememberApproved = false;
185198
if (missing.size > 0) {
@@ -366,6 +379,15 @@ export class StopGapWidgetDriver extends WidgetDriver {
366379
}
367380

368381
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
382+
const opts: ApprovalOpts = { approved: undefined };
383+
ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, opts, this.forWidget);
384+
if (opts.approved) {
385+
return observer.update({
386+
state: OpenIDRequestState.Allowed,
387+
token: await MatrixClientPeg.get().getOpenIdToken(),
388+
});
389+
}
390+
369391
const oidcState = SdkContextClass.instance.widgetPermissionStore.getOIDCState(
370392
this.forWidget,
371393
this.forWidgetKind,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
Copyright 2023 Mikhail Aheichyk
3+
Copyright 2023 Nordeck IT + Consulting GmbH.
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
import React from "react";
19+
import { screen, render } from "@testing-library/react";
20+
import userEvent from "@testing-library/user-event";
21+
import { MatrixClient } from "matrix-js-sdk/src/client";
22+
import { MatrixWidgetType } from "matrix-widget-api";
23+
import {
24+
ApprovalOpts,
25+
WidgetInfo,
26+
WidgetLifecycle,
27+
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
28+
29+
import {
30+
WidgetContextMenu,
31+
WidgetContextMenuProps,
32+
} from "../../../../src/components/views/context_menus/WidgetContextMenu";
33+
import { IApp } from "../../../../src/stores/WidgetStore";
34+
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
35+
import WidgetUtils from "../../../../src/utils/WidgetUtils";
36+
import { ModuleRunner } from "../../../../src/modules/ModuleRunner";
37+
import SettingsStore from "../../../../src/settings/SettingsStore";
38+
39+
describe("<WidgetContextMenu />", () => {
40+
const widgetId = "w1";
41+
const eventId = "e1";
42+
const roomId = "r1";
43+
const userId = "@user-id:server";
44+
45+
const app: IApp = {
46+
id: widgetId,
47+
eventId,
48+
roomId,
49+
type: MatrixWidgetType.Custom,
50+
url: "https://example.com",
51+
name: "Example 1",
52+
creatorUserId: userId,
53+
avatar_url: undefined,
54+
};
55+
56+
const mockClient = {
57+
getUserId: jest.fn().mockReturnValue(userId),
58+
} as unknown as MatrixClient;
59+
60+
let onFinished: () => void;
61+
62+
beforeEach(() => {
63+
onFinished = jest.fn();
64+
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
65+
});
66+
67+
afterEach(() => {
68+
jest.restoreAllMocks();
69+
});
70+
71+
function getComponent(props: Partial<WidgetContextMenuProps> = {}): JSX.Element {
72+
return (
73+
<MatrixClientContext.Provider value={mockClient}>
74+
<WidgetContextMenu app={app} onFinished={onFinished} {...props} />
75+
</MatrixClientContext.Provider>
76+
);
77+
}
78+
79+
it("renders revoke button", async () => {
80+
const { rerender } = render(getComponent());
81+
82+
const revokeButton = screen.getByLabelText("Revoke permissions");
83+
expect(revokeButton).toBeInTheDocument();
84+
85+
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => {
86+
if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === widgetId) {
87+
(opts as ApprovalOpts).approved = true;
88+
}
89+
});
90+
91+
rerender(getComponent());
92+
expect(revokeButton).not.toBeInTheDocument();
93+
});
94+
95+
it("revokes permissions", async () => {
96+
render(getComponent());
97+
await userEvent.click(screen.getByLabelText("Revoke permissions"));
98+
expect(onFinished).toHaveBeenCalled();
99+
expect(SettingsStore.getValue("allowedWidgets", roomId)[eventId]).toBe(false);
100+
});
101+
});

test/components/views/elements/AppTile-test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ import { act, render, RenderResult } from "@testing-library/react";
2323
import userEvent from "@testing-library/user-event";
2424
import { MatrixClient } from "matrix-js-sdk/src/matrix";
2525
import { SpiedFunction } from "jest-mock";
26+
import {
27+
ApprovalOpts,
28+
WidgetInfo,
29+
WidgetLifecycle,
30+
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
2631

2732
import RightPanel from "../../../../src/components/structures/RightPanel";
2833
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
@@ -44,6 +49,7 @@ import AppsDrawer from "../../../../src/components/views/rooms/AppsDrawer";
4449
import { ElementWidgetCapabilities } from "../../../../src/stores/widgets/ElementWidgetCapabilities";
4550
import { ElementWidget } from "../../../../src/stores/widgets/StopGapWidget";
4651
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
52+
import { ModuleRunner } from "../../../../src/modules/ModuleRunner";
4753

4854
describe("AppTile", () => {
4955
let cli: MatrixClient;
@@ -380,4 +386,21 @@ describe("AppTile", () => {
380386
});
381387
});
382388
});
389+
390+
it("for a pinned widget permission load", () => {
391+
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => {
392+
if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === app1.id) {
393+
(opts as ApprovalOpts).approved = true;
394+
}
395+
});
396+
397+
// userId and creatorUserId are different
398+
const renderResult = render(
399+
<MatrixClientContext.Provider value={cli}>
400+
<AppTile key={app1.id} app={app1} room={r1} userId="@user1" creatorUserId="@userAnother" />
401+
</MatrixClientContext.Provider>,
402+
);
403+
404+
expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
405+
});
383406
});

0 commit comments

Comments
 (0)