mirror of
https://github.com/vector-im/element-web.git
synced 2026-03-04 04:52:04 +01:00
Implement new widget permissions module api (#32565)
* Add widget lifecycle API at top level * Integrate while still falling back to the legacy api * Remove WidgetKind * Update module api to the one that includes the new widget lifecycle api * lint * Make preload checks easier to understand - Have single code path for preload checks. - Remove duplicated logic for preapproveIdentity check - Fix headers * lint
This commit is contained in:
parent
611e924dc2
commit
cea684c065
@ -58,6 +58,8 @@ import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidget
|
||||
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { ModuleRunner } from "../../../modules/ModuleRunner";
|
||||
import { ModuleApi } from "../../../modules/Api";
|
||||
import { toWidgetDescriptor } from "../../../modules/WidgetLifecycleApi";
|
||||
import { parseUrl } from "../../../utils/UrlUtils";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts";
|
||||
@ -191,10 +193,21 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||
this.setState({ isUserProfileReady: true });
|
||||
};
|
||||
|
||||
// This is a function to make the impact of calling SettingsStore slightly less
|
||||
private hasPermissionToLoad = (props: IProps): boolean => {
|
||||
/**
|
||||
* Synchronous permission check (fast path).
|
||||
*
|
||||
* Returns true when the widget can be loaded immediately, based on:
|
||||
* - local (Jitsi) widgets,
|
||||
* - account-level (user) widgets (no room),
|
||||
* - the **legacy** module API ({@link WidgetLifecycle.PreLoadRequest}),
|
||||
* - explicit user consent stored in the `allowedWidgets` setting,
|
||||
* - the current user being the widget creator.
|
||||
*/
|
||||
private hasPermissionToLoadSync = (props: IProps): boolean => {
|
||||
if (this.usingLocalWidget()) return true;
|
||||
if (!props.room) return true; // user widgets always have permissions
|
||||
|
||||
// Legacy module API (synchronous)
|
||||
const opts: ApprovalOpts = { approved: undefined };
|
||||
ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(this.props.app));
|
||||
if (opts.approved) return true;
|
||||
@ -207,6 +220,36 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||
return allowed || props.userId === props.creatorUserId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified permission check that consults **both** the legacy (sync) and
|
||||
* new (async) module APIs.
|
||||
*
|
||||
* 1. Runs the fast synchronous checks ({@link hasPermissionToLoadSync}).
|
||||
* If any approve, resolves `true` immediately.
|
||||
* 2. Falls back to the new module API
|
||||
* ({@link ModuleApi.widgetLifecycle.preapprovePreload}) which may be
|
||||
* async (config look-ups, network calls, etc.).
|
||||
* 3. Returns `false` only when **neither** API grants permission.
|
||||
*
|
||||
* Every call site that needs to know "can this widget load?" should go
|
||||
* through this method so the two APIs are never accidentally divergent.
|
||||
*/
|
||||
private async resolvePermissionToLoad(props: IProps): Promise<boolean> {
|
||||
// Phase 1 – synchronous checks (legacy module API, settings, creator)
|
||||
if (this.hasPermissionToLoadSync(props)) return true;
|
||||
|
||||
// Phase 2 – async new module API
|
||||
if (!props.room) return false; // only room widgets go through the async path
|
||||
try {
|
||||
const descriptor = toWidgetDescriptor(this.widget, props.room.roomId);
|
||||
const approved = await ModuleApi.instance.widgetLifecycle.preapprovePreload(descriptor);
|
||||
return approved === true;
|
||||
} catch (err) {
|
||||
logger.error("Module API preload approval check failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private onUserLeftRoom(): void {
|
||||
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(
|
||||
this.props.app.id,
|
||||
@ -269,9 +312,9 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||
// Don't show loading at all if the widget is ready once the IFrame is loaded (waitForIframeLoad = true).
|
||||
// We only need the loading screen if the widget sends a contentLoaded event (waitForIframeLoad = false).
|
||||
loading: !this.props.waitForIframeLoad && !PersistedElement.isMounted(this.persistKey),
|
||||
// Assume that widget has permission to load if we are the user who
|
||||
// added it to the room, or if explicitly granted by the user
|
||||
hasPermissionToLoad: this.hasPermissionToLoad(newProps),
|
||||
// Use the sync check for the initial render (constructor can't await).
|
||||
// componentDidMount will immediately follow up with the full async check.
|
||||
hasPermissionToLoad: this.hasPermissionToLoadSync(newProps),
|
||||
isUserProfileReady: OwnProfileStore.instance.isProfileInfoFetched,
|
||||
error: null,
|
||||
menuDisplayed: false,
|
||||
@ -280,19 +323,25 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
private onAllowedWidgetsChange = (): void => {
|
||||
const hasPermissionToLoad = this.hasPermissionToLoad(this.props);
|
||||
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
|
||||
// Force the widget to be non-persistent (able to be deleted/forgotten)
|
||||
ActiveWidgetStore.instance.destroyPersistentWidget(
|
||||
this.props.app.id,
|
||||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||||
);
|
||||
PersistedElement.destroyElement(this.persistKey);
|
||||
this.messaging?.stop();
|
||||
}
|
||||
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
|
||||
// Force the widget to be non-persistent (able to be deleted/forgotten)
|
||||
ActiveWidgetStore.instance.destroyPersistentWidget(
|
||||
this.props.app.id,
|
||||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||||
);
|
||||
PersistedElement.destroyElement(this.persistKey);
|
||||
this.messaging?.stop();
|
||||
}
|
||||
|
||||
this.setState({ hasPermissionToLoad });
|
||||
if (!this.state.hasPermissionToLoad && hasPermissionToLoad) {
|
||||
this.startWidget();
|
||||
}
|
||||
|
||||
this.setState({ hasPermissionToLoad });
|
||||
});
|
||||
};
|
||||
|
||||
private isMixedContent(): boolean {
|
||||
@ -327,9 +376,19 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||
this.setupMessagingListeners();
|
||||
}
|
||||
|
||||
// Only fetch IM token on mount if we're showing and have permission to load
|
||||
if (this.messaging && this.state.hasPermissionToLoad) {
|
||||
// The constructor used the sync-only fast path for initial state.
|
||||
// Now run the full check (sync + async) to catch any module API approvals.
|
||||
if (this.state.hasPermissionToLoad && this.messaging) {
|
||||
// Sync check already approved — start immediately, no need to re-check.
|
||||
this.startWidget();
|
||||
} else {
|
||||
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
|
||||
if (this.unmounted) return;
|
||||
this.setState({ hasPermissionToLoad });
|
||||
if (hasPermissionToLoad) {
|
||||
this.startWidget();
|
||||
}
|
||||
});
|
||||
}
|
||||
this.watchUserReady();
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ import { ElementWebExtrasApi } from "./ExtrasApi.ts";
|
||||
import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
|
||||
import { ClientApi } from "./ClientApi.ts";
|
||||
import { StoresApi } from "./StoresApi.ts";
|
||||
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
|
||||
|
||||
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
|
||||
let used = false;
|
||||
@ -85,6 +86,7 @@ export class ModuleApi implements Api {
|
||||
public readonly customComponents = new CustomComponentsApi();
|
||||
public readonly extras = new ElementWebExtrasApi();
|
||||
public readonly builtins = new ElementWebBuiltinsApi();
|
||||
public readonly widgetLifecycle = new WidgetLifecycleApi();
|
||||
public readonly rootNode = document.getElementById("matrixchat")!;
|
||||
public readonly client = new ClientApi();
|
||||
public readonly stores = new StoresApi();
|
||||
|
||||
121
apps/web/src/modules/WidgetLifecycleApi.ts
Normal file
121
apps/web/src/modules/WidgetLifecycleApi.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Widget } from "matrix-widget-api";
|
||||
import type {
|
||||
CapabilitiesApprover,
|
||||
IdentityApprover,
|
||||
PreloadApprover,
|
||||
WidgetDescriptor,
|
||||
WidgetLifecycleApi as WidgetLifecycleApiInterface,
|
||||
} from "@element-hq/element-web-module-api";
|
||||
|
||||
/**
|
||||
* Converts a matrix-widget-api {@link Widget} into a {@link WidgetDescriptor} for the module API.
|
||||
* @param widget - The widget instance from matrix-widget-api.
|
||||
* @param roomId - The room ID the widget belongs to, if applicable.
|
||||
*/
|
||||
export const toWidgetDescriptor = (widget: Widget, roomId?: string): WidgetDescriptor => {
|
||||
return {
|
||||
id: widget.id,
|
||||
templateUrl: widget.templateUrl,
|
||||
creatorUserId: widget.creatorUserId,
|
||||
type: widget.type,
|
||||
origin: widget.origin,
|
||||
roomId,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Host-side implementation of the widget lifecycle API.
|
||||
* Allows a single module to register approver callbacks for widget preloading,
|
||||
* identity token requests, and capability requests. Only one approver per slot
|
||||
* is supported; attempting to register a second throws an error.
|
||||
*/
|
||||
export class WidgetLifecycleApi implements WidgetLifecycleApiInterface {
|
||||
private preloadApprover?: PreloadApprover;
|
||||
private identityApprover?: IdentityApprover;
|
||||
private capabilitiesApprover?: CapabilitiesApprover;
|
||||
|
||||
private ensureApproverUnset<T>(current: T | undefined, name: string): void {
|
||||
if (current) {
|
||||
throw new Error(`Widget lifecycle ${name} approver already registered`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler that can auto-approve widget preloading.
|
||||
* Only one preload approver may be registered; a second call throws.
|
||||
*/
|
||||
public registerPreloadApprover(approver: PreloadApprover): void {
|
||||
this.ensureApproverUnset(this.preloadApprover, "preload");
|
||||
this.preloadApprover = approver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler that can auto-approve identity token requests.
|
||||
* Only one identity approver may be registered; a second call throws.
|
||||
*/
|
||||
public registerIdentityApprover(approver: IdentityApprover): void {
|
||||
this.ensureApproverUnset(this.identityApprover, "identity");
|
||||
this.identityApprover = approver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler that can auto-approve widget capabilities.
|
||||
* Only one capabilities approver may be registered; a second call throws.
|
||||
*/
|
||||
public registerCapabilitiesApprover(approver: CapabilitiesApprover): void {
|
||||
this.ensureApproverUnset(this.capabilitiesApprover, "capabilities");
|
||||
this.capabilitiesApprover = approver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the registered preload approver for the given widget.
|
||||
* @returns `true` if the module approved preloading, `false` otherwise.
|
||||
*/
|
||||
public async preapprovePreload(widget: WidgetDescriptor): Promise<boolean> {
|
||||
if (!this.preloadApprover) return false;
|
||||
try {
|
||||
return (await this.preloadApprover(widget)) === true;
|
||||
} catch (error) {
|
||||
console.error("Widget preload approver failed", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the registered identity approver for the given widget.
|
||||
* @returns `true` if the module approved the identity token request, `false` otherwise.
|
||||
*/
|
||||
public async preapproveIdentity(widget: WidgetDescriptor): Promise<boolean> {
|
||||
if (!this.identityApprover) return false;
|
||||
try {
|
||||
return (await this.identityApprover(widget)) === true;
|
||||
} catch (error) {
|
||||
console.error("Widget identity approver failed", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the registered capabilities approver for the given widget.
|
||||
* @returns The set of approved capabilities, or `undefined` to defer to the default consent flow.
|
||||
*/
|
||||
public async preapproveCapabilities(
|
||||
widget: WidgetDescriptor,
|
||||
requestedCapabilities: Set<string>,
|
||||
): Promise<Set<string> | undefined> {
|
||||
if (!this.capabilitiesApprover) return undefined;
|
||||
try {
|
||||
return await this.capabilitiesApprover(widget, requestedCapabilities);
|
||||
} catch (error) {
|
||||
console.error("Widget capabilities approver failed", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -64,6 +64,8 @@ import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
|
||||
import { navigateToPermalink } from "../../utils/permalinks/navigator";
|
||||
import { SdkContextClass } from "../../contexts/SDKContext";
|
||||
import { ModuleRunner } from "../../modules/ModuleRunner";
|
||||
import { ModuleApi } from "../../modules/Api";
|
||||
import { toWidgetDescriptor } from "../../modules/WidgetLifecycleApi";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
|
||||
@ -250,13 +252,20 @@ export class ElementWidgetDriver extends WidgetDriver {
|
||||
missing.delete(cap);
|
||||
});
|
||||
|
||||
// Try the new module API first, then fall back to legacy paths
|
||||
let approved: Set<string> | undefined;
|
||||
if (WidgetPermissionCustomisations.preapproveCapabilities) {
|
||||
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;
|
||||
approved = await ModuleApi.instance.widgetLifecycle.preapproveCapabilities(
|
||||
toWidgetDescriptor(this.forWidget, this.inRoomId),
|
||||
requested,
|
||||
);
|
||||
if (!approved) {
|
||||
if (WidgetPermissionCustomisations.preapproveCapabilities) {
|
||||
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) => {
|
||||
@ -663,9 +672,19 @@ export class ElementWidgetDriver 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) {
|
||||
// Try the new module API first, then fall back to legacy path
|
||||
let approved: boolean | undefined = await ModuleApi.instance.widgetLifecycle.preapproveIdentity(
|
||||
toWidgetDescriptor(this.forWidget, this.inRoomId),
|
||||
);
|
||||
|
||||
if (!approved) {
|
||||
// Legacy module API fallback
|
||||
const legacyOpts: ApprovalOpts = { approved: undefined };
|
||||
ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, legacyOpts, this.forWidget);
|
||||
approved = legacyOpts.approved;
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
return observer.update({
|
||||
state: OpenIDRequestState.Allowed,
|
||||
token: await MatrixClientPeg.safeGet().getOpenIdToken(),
|
||||
|
||||
@ -37,6 +37,7 @@ import { ElementWidgetCapabilities } from "../../../../../src/stores/widgets/Ele
|
||||
import { ElementWidget, type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
|
||||
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
|
||||
import { ModuleRunner } from "../../../../../src/modules/ModuleRunner";
|
||||
import { ModuleApi } from "../../../../../src/modules/Api";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
|
||||
@ -426,6 +427,30 @@ describe("AppTile", () => {
|
||||
expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should auto-approve preload via new widget lifecycle API", async () => {
|
||||
// Legacy module API denies preload
|
||||
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => {
|
||||
if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === app1.id) {
|
||||
(opts as ApprovalOpts).approved = false;
|
||||
}
|
||||
});
|
||||
|
||||
// New API approves preload
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapprovePreload").mockResolvedValue(true);
|
||||
|
||||
// userId and creatorUserId are different so legacy path would show "Continue"
|
||||
const renderResult = render(
|
||||
<MatrixClientContext.Provider value={cli}>
|
||||
<AppTile key={app1.id} app={app1} room={r1} userId="@user1" creatorUserId="@userAnother" />
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
// The new API runs async in componentDidMount, so wait for it to take effect
|
||||
await waitFor(() => {
|
||||
expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a maximised (centered) widget", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockImplementation(
|
||||
|
||||
201
apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts
Normal file
201
apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts
Normal file
@ -0,0 +1,201 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Widget } from "matrix-widget-api";
|
||||
|
||||
import type { WidgetDescriptor } from "@element-hq/element-web-module-api";
|
||||
import { WidgetLifecycleApi, toWidgetDescriptor } from "../../../src/modules/WidgetLifecycleApi";
|
||||
|
||||
const mkDescriptor = (overrides: Partial<WidgetDescriptor> = {}): WidgetDescriptor => ({
|
||||
id: "test-widget",
|
||||
templateUrl: "https://example.org/widget",
|
||||
creatorUserId: "@alice:example.org",
|
||||
type: "m.custom",
|
||||
origin: "https://example.org",
|
||||
roomId: "!room:example.org",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("WidgetLifecycleApi", () => {
|
||||
let api: WidgetLifecycleApi;
|
||||
|
||||
beforeEach(() => {
|
||||
api = new WidgetLifecycleApi();
|
||||
});
|
||||
|
||||
describe("toWidgetDescriptor", () => {
|
||||
it("converts a Widget to a WidgetDescriptor", () => {
|
||||
const widget = new Widget({
|
||||
id: "w1",
|
||||
creatorUserId: "@bob:example.org",
|
||||
type: "m.jitsi",
|
||||
url: "https://jitsi.example.org/meet?conf=$matrix_room_id",
|
||||
});
|
||||
const descriptor = toWidgetDescriptor(widget, "!room:example.org");
|
||||
expect(descriptor).toEqual({
|
||||
id: "w1",
|
||||
templateUrl: "https://jitsi.example.org/meet?conf=$matrix_room_id",
|
||||
creatorUserId: "@bob:example.org",
|
||||
type: "m.jitsi",
|
||||
origin: "https://jitsi.example.org",
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits roomId when not provided", () => {
|
||||
const widget = new Widget({
|
||||
id: "w1",
|
||||
creatorUserId: "@bob:example.org",
|
||||
type: "m.custom",
|
||||
url: "https://example.org",
|
||||
});
|
||||
const descriptor = toWidgetDescriptor(widget);
|
||||
expect(descriptor.roomId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerPreloadApprover", () => {
|
||||
it("accepts a single registration", () => {
|
||||
expect(() => api.registerPreloadApprover(() => true)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on double registration", () => {
|
||||
api.registerPreloadApprover(() => true);
|
||||
expect(() => api.registerPreloadApprover(() => true)).toThrow(
|
||||
"Widget lifecycle preload approver already registered",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerIdentityApprover", () => {
|
||||
it("accepts a single registration", () => {
|
||||
expect(() => api.registerIdentityApprover(() => true)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on double registration", () => {
|
||||
api.registerIdentityApprover(() => true);
|
||||
expect(() => api.registerIdentityApprover(() => true)).toThrow(
|
||||
"Widget lifecycle identity approver already registered",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerCapabilitiesApprover", () => {
|
||||
it("accepts a single registration", () => {
|
||||
expect(() => api.registerCapabilitiesApprover(() => new Set())).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on double registration", () => {
|
||||
api.registerCapabilitiesApprover(() => new Set());
|
||||
expect(() => api.registerCapabilitiesApprover(() => new Set())).toThrow(
|
||||
"Widget lifecycle capabilities approver already registered",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preapprovePreload", () => {
|
||||
const widget = mkDescriptor();
|
||||
|
||||
it("returns false when no approver registered", async () => {
|
||||
expect(await api.preapprovePreload(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when approver returns true", async () => {
|
||||
api.registerPreloadApprover(() => true);
|
||||
expect(await api.preapprovePreload(widget)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when approver returns false", async () => {
|
||||
api.registerPreloadApprover(() => false);
|
||||
expect(await api.preapprovePreload(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when approver returns undefined", async () => {
|
||||
api.registerPreloadApprover(() => undefined);
|
||||
expect(await api.preapprovePreload(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs error when approver throws", async () => {
|
||||
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
api.registerPreloadApprover(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(await api.preapprovePreload(widget)).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Widget preload approver failed", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preapproveIdentity", () => {
|
||||
const widget = mkDescriptor();
|
||||
|
||||
it("returns false when no approver registered", async () => {
|
||||
expect(await api.preapproveIdentity(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when approver returns true", async () => {
|
||||
api.registerIdentityApprover(() => true);
|
||||
expect(await api.preapproveIdentity(widget)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when approver returns false", async () => {
|
||||
api.registerIdentityApprover(() => false);
|
||||
expect(await api.preapproveIdentity(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when approver returns undefined", async () => {
|
||||
api.registerIdentityApprover(() => undefined);
|
||||
expect(await api.preapproveIdentity(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs error when approver throws", async () => {
|
||||
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
api.registerIdentityApprover(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(await api.preapproveIdentity(widget)).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Widget identity approver failed", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preapproveCapabilities", () => {
|
||||
const widget = mkDescriptor();
|
||||
const requested = new Set(["cap1", "cap2", "cap3"]);
|
||||
|
||||
it("returns undefined when no approver registered", async () => {
|
||||
expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the set from the approver", async () => {
|
||||
api.registerCapabilitiesApprover(() => new Set(["cap1", "cap2"]));
|
||||
expect(await api.preapproveCapabilities(widget, requested)).toEqual(new Set(["cap1", "cap2"]));
|
||||
});
|
||||
|
||||
it("passes widget and requested capabilities to the approver", async () => {
|
||||
const approver = jest.fn().mockReturnValue(new Set(["cap1"]));
|
||||
api.registerCapabilitiesApprover(approver);
|
||||
await api.preapproveCapabilities(widget, requested);
|
||||
expect(approver).toHaveBeenCalledWith(widget, requested);
|
||||
});
|
||||
|
||||
it("returns undefined when approver returns undefined", async () => {
|
||||
api.registerCapabilitiesApprover(() => undefined);
|
||||
expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when approver throws", async () => {
|
||||
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
api.registerCapabilitiesApprover(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Widget capabilities approver failed", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -39,6 +39,7 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import { ElementWidgetDriver } from "../../../../src/stores/widgets/ElementWidgetDriver";
|
||||
import { mkEvent, stubClient } from "../../../test-utils";
|
||||
import { ModuleRunner } from "../../../../src/modules/ModuleRunner";
|
||||
import { ModuleApi } from "../../../../src/modules/Api";
|
||||
import dis from "../../../../src/dispatcher/dispatcher";
|
||||
import Modal from "../../../../src/Modal";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
@ -187,6 +188,76 @@ describe("ElementWidgetDriver", () => {
|
||||
expect(listener).toHaveBeenCalledWith(openIdUpdate);
|
||||
});
|
||||
|
||||
it("approves capabilities via new widget lifecycle API", async () => {
|
||||
const driver = mkDefaultDriver();
|
||||
|
||||
const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]);
|
||||
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveCapabilities").mockResolvedValue(
|
||||
requestedCapabilities,
|
||||
);
|
||||
|
||||
const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities);
|
||||
expect(approvedCapabilities).toEqual(requestedCapabilities);
|
||||
});
|
||||
|
||||
it("falls back to legacy module API when new API returns undefined for capabilities", async () => {
|
||||
const driver = mkDefaultDriver();
|
||||
|
||||
const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]);
|
||||
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveCapabilities").mockResolvedValue(undefined);
|
||||
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 new widget lifecycle API", async () => {
|
||||
const driver = mkDefaultDriver();
|
||||
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveIdentity").mockResolvedValue(true);
|
||||
|
||||
const listener = jest.fn();
|
||||
const observer = new SimpleObservable<IOpenIDUpdate>();
|
||||
observer.onUpdate(listener);
|
||||
await driver.askOpenID(observer);
|
||||
|
||||
const openIdUpdate: IOpenIDUpdate = {
|
||||
state: OpenIDRequestState.Allowed,
|
||||
token: await client.getOpenIdToken(),
|
||||
};
|
||||
expect(listener).toHaveBeenCalledWith(openIdUpdate);
|
||||
});
|
||||
|
||||
it("falls back to legacy module API when new API returns false for identity", async () => {
|
||||
const driver = mkDefaultDriver();
|
||||
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveIdentity").mockResolvedValue(false);
|
||||
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<IOpenIDUpdate>();
|
||||
observer.onUpdate(listener);
|
||||
await driver.askOpenID(observer);
|
||||
|
||||
const openIdUpdate: IOpenIDUpdate = {
|
||||
state: OpenIDRequestState.Allowed,
|
||||
token: await client.getOpenIdToken(),
|
||||
};
|
||||
expect(listener).toHaveBeenCalledWith(openIdUpdate);
|
||||
});
|
||||
|
||||
describe("sendToDevice", () => {
|
||||
const contentMap = {
|
||||
"@alice:example.org": {
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@ -7,8 +7,8 @@ settings:
|
||||
catalogs:
|
||||
default:
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: 1.9.1
|
||||
version: 1.9.1
|
||||
specifier: 1.10.0
|
||||
version: 1.10.0
|
||||
'@element-hq/element-web-playwright-common':
|
||||
specifier: 2.2.7
|
||||
version: 2.2.7
|
||||
@ -143,7 +143,7 @@ importers:
|
||||
version: 7.28.6
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: 'catalog:'
|
||||
version: 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
'@element-hq/web-shared-components':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-components
|
||||
@ -417,7 +417,7 @@ importers:
|
||||
version: 0.16.3
|
||||
'@element-hq/element-web-playwright-common':
|
||||
specifier: 'catalog:'
|
||||
version: 2.2.7(@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)
|
||||
version: 2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)
|
||||
'@element-hq/element-web-playwright-common-local':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/playwright-common
|
||||
@ -756,7 +756,7 @@ importers:
|
||||
dependencies:
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: 'catalog:'
|
||||
version: 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
'@matrix-org/spec':
|
||||
specifier: ^1.7.0
|
||||
version: 1.16.0
|
||||
@ -2034,8 +2034,8 @@ packages:
|
||||
'@element-hq/element-call-embedded@0.16.3':
|
||||
resolution: {integrity: sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==}
|
||||
|
||||
'@element-hq/element-web-module-api@1.9.1':
|
||||
resolution: {integrity: sha512-eCHHBkaDWc7Ai10b2VmOAkWIQAsur+YZ2kpFrPFVG41wMXn0PbFL+n6wvpbN+mU5Mg7uVIqXhQn4jflHESBUrA==}
|
||||
'@element-hq/element-web-module-api@1.10.0':
|
||||
resolution: {integrity: sha512-XIl6E73dn0cmR/03TRCpq7epyFQAa93GUz1j7EBP2pv5Erh59gq788ajFM2XYl7W2afRU0aasvJoI3iZIHbRig==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@matrix-org/react-sdk-module-api': '*'
|
||||
@ -12081,7 +12081,7 @@ snapshots:
|
||||
|
||||
'@element-hq/element-call-embedded@0.16.3': {}
|
||||
|
||||
'@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)':
|
||||
'@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.10
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.10)
|
||||
@ -12090,10 +12090,10 @@ snapshots:
|
||||
'@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4)
|
||||
matrix-web-i18n: 3.6.0
|
||||
|
||||
'@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)':
|
||||
'@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)':
|
||||
dependencies:
|
||||
'@axe-core/playwright': 4.11.1(playwright-core@1.58.2)
|
||||
'@element-hq/element-web-module-api': 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
'@element-hq/element-web-module-api': 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
|
||||
'@playwright/test': 1.58.2
|
||||
'@testcontainers/postgresql': 11.11.0
|
||||
glob: 13.0.6
|
||||
|
||||
@ -16,7 +16,7 @@ catalog:
|
||||
"@element-hq/element-web-playwright-common": 2.2.7
|
||||
"@playwright/test": 1.58.2
|
||||
# Module API
|
||||
"@element-hq/element-web-module-api": 1.9.1
|
||||
"@element-hq/element-web-module-api": 1.10.0
|
||||
# Compound
|
||||
"@vector-im/compound-design-tokens": 6.10.0
|
||||
"@vector-im/compound-web": 8.4.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user