Absorb remainder of element-modules into monorepo
@@ -0,0 +1 @@
|
||||
**/node_modules/
|
||||
@@ -0,0 +1,75 @@
|
||||
module.exports = {
|
||||
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
|
||||
extends: ["plugin:matrix-org/typescript", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
"react/jsx-key": ["error"],
|
||||
|
||||
"no-restricted-properties": [
|
||||
"error",
|
||||
...buildRestrictedPropertiesOptions(
|
||||
["window.innerHeight", "window.innerWidth", "window.visualViewport"],
|
||||
"Use UIStore to access window dimensions instead.",
|
||||
),
|
||||
...buildRestrictedPropertiesOptions(
|
||||
["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
|
||||
"Use Media helper instead to centralise access for customisation.",
|
||||
),
|
||||
...buildRestrictedPropertiesOptions(["window.setImmediate"], "Use setTimeout instead."),
|
||||
],
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
name: "setImmediate",
|
||||
message: "Use setTimeout instead.",
|
||||
},
|
||||
{
|
||||
name: "Buffer",
|
||||
message: "Buffer is not available in the web.",
|
||||
},
|
||||
],
|
||||
|
||||
"import/no-duplicates": ["error"],
|
||||
"matrix-org/require-copyright-header": "error",
|
||||
|
||||
"react-compiler/react-compiler": "error",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["playwright/**/*.ts", "modules/*/e2e/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
// This is necessary for Playwright fixtures
|
||||
"no-empty-pattern": "off",
|
||||
// This is necessary for Playwright fixtures
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
// This just gets annoying in test code
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function buildRestrictedPropertiesOptions(properties, message) {
|
||||
return properties.map((prop) => {
|
||||
let [object, property] = prop.split(".");
|
||||
if (object === "*") {
|
||||
object = undefined;
|
||||
}
|
||||
return {
|
||||
object,
|
||||
property,
|
||||
message,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
lib/
|
||||
temp/
|
||||
coverage/
|
||||
|
||||
/playwright-html-report/
|
||||
**/test-results
|
||||
**/_results
|
||||
# Only commit snapshots from Linux
|
||||
**/e2e/snapshots/**/*.png
|
||||
**/e2e/snapshots/**/*.yml
|
||||
!**/e2e/snapshots/**/*-linux.png
|
||||
!**/e2e/snapshots/**/*-linux.aria.yml
|
||||
/playwright-report/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"*": "prettier -u --write"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
ARG ELEMENT_VERSION=latest@sha256:a84f294ce46e4327ebacecb78bfc94cf6a45c7ffa5104a28f06b5ac69d0b2548
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 AS builder
|
||||
|
||||
ARG BUILD_CONTEXT
|
||||
|
||||
RUN apk add --no-cache jq
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
# Copy the package.json files of all modules & packages to ensure the frozen workspace lockfile holds up
|
||||
RUN --mount=type=bind,target=/docker-context \
|
||||
cd /docker-context/; \
|
||||
find . -path ./node_modules -prune -o -name "package.json" -mindepth 0 -maxdepth 4 -exec cp --parents "{}" /app/ \;
|
||||
RUN yarn install --frozen-lockfile --ignore-scripts
|
||||
COPY tsconfig.json ./
|
||||
COPY ./$BUILD_CONTEXT ./$BUILD_CONTEXT
|
||||
RUN cd $BUILD_CONTEXT && yarn vite build
|
||||
RUN mkdir /modules
|
||||
RUN cp -r ./$BUILD_CONTEXT/lib/ /modules/$(jq -r '"\(.name)-v\(.version)"' ./$BUILD_CONTEXT/package.json)
|
||||
|
||||
FROM ghcr.io/element-hq/element-web:${ELEMENT_VERSION}
|
||||
|
||||
COPY --from=builder /modules /modules/
|
||||
@@ -0,0 +1 @@
|
||||
Element Web Modules (based on @element-hq/element-web-module-api) which we maintain live here.
|
||||
@@ -0,0 +1,57 @@
|
||||
# @element-hq/element-web-module-banner
|
||||
|
||||
Banner module for Element Web.
|
||||
Allows rendering a top bar with slide out left panel menu.
|
||||
|
||||
Supports the following configuration options:
|
||||
|
||||
| Key | Type | Description |
|
||||
| ------------- | ------ | ---------------------------------------------------------------------------------------------- |
|
||||
| logo_url | string | URL to the logo to render in the banner |
|
||||
| logo_link_url | string | URL to send the user to when clicking the logo in the banner |
|
||||
| title | string | The title to render next to the logo, falls back to top level `brand` variable if unspecified. |
|
||||
| menu | `Menu` | Data to render in the banner menu |
|
||||
|
||||
The `Menu` type is fulfilled by the following discriminated union:
|
||||
|
||||
### Univention menu
|
||||
|
||||
| Key | Type | Description |
|
||||
| ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| type | "univention" | The type for this menu config |
|
||||
| ics_url | string | URL to the UCS Intercom Service, https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints |
|
||||
|
||||
### Static menu
|
||||
|
||||
| Key | Type | Description |
|
||||
| ---------- | ---------------- | ------------------------------------------------------------------------- |
|
||||
| type | "static" | The type for this menu config |
|
||||
| logo_url | string, optional | URL to the logo to render in the menu, defaults to banner logo if omitted |
|
||||
| categories | `[]Category` | Categories to render in the menu |
|
||||
|
||||
The `Category` type is fulfilled by the following interface:
|
||||
|
||||
| Key | Type | Description |
|
||||
| ----- | -------- | -------------------------------- |
|
||||
| name | string | The name of this category |
|
||||
| links | `[]Link` | Links to render in this category |
|
||||
|
||||
The `Link` type is fulfilled by the following interface:
|
||||
|
||||
| Key | Type | Description |
|
||||
| -------- | ---------------- | --------------------------------------- |
|
||||
| icon_uri | string | URL to the icon to render for this link |
|
||||
| name | string | The name to render for this link |
|
||||
| link_url | string | The URL to link to |
|
||||
| target | string, optional | The `target` to use for this link |
|
||||
|
||||
## Copyright & License
|
||||
|
||||
Copyright (c) 2025 New Vector Ltd
|
||||
|
||||
This software is multi licensed by New Vector Ltd (Element). It can be used either:
|
||||
|
||||
(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR
|
||||
|
||||
(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to).
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses.
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { input } from "zod/mini";
|
||||
|
||||
import { test as base, expect } from "../../playwright/element-web-test.ts";
|
||||
import { type ConfigSchema } from "../src/config.ts";
|
||||
|
||||
const test = base.extend<{
|
||||
// Resolver for when to respond to the navigation.json request
|
||||
navigationJsonResolver: PromiseWithResolvers<void>;
|
||||
}>({
|
||||
navigationJsonResolver: async ({}, use) => {
|
||||
await use(Promise.withResolvers<void>());
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Banner", () => {
|
||||
test.use({
|
||||
displayName: "Timmy",
|
||||
page: async ({ context, page, moduleDir }, use) => {
|
||||
for (const path of ["logo.svg", "app1.png", "app2.png", "opendesk/"]) {
|
||||
await context.route(`/${path}*`, async (route) => {
|
||||
const file = new URL(route.request().url()).pathname;
|
||||
await route.fulfill({ path: `${moduleDir}/e2e/fixture/${file}` });
|
||||
});
|
||||
}
|
||||
|
||||
await page.goto("/");
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
test("should error if config is missing", async ({ page }) => {
|
||||
await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
|
||||
await expect(page.getByText("Errors in module configuration")).toBeVisible();
|
||||
// We don't take a screenshot as we don't want to assert Element's styling, only our own
|
||||
});
|
||||
|
||||
const configs: input<ConfigSchema>[] = [
|
||||
{
|
||||
logo_url: "http://localhost:8080/logo.svg",
|
||||
logo_link_url: "https://example.com/portal",
|
||||
menu: {
|
||||
type: "static",
|
||||
categories: [
|
||||
{
|
||||
name: "Applications",
|
||||
links: [
|
||||
{
|
||||
icon_uri: "http://localhost:8080/app1.png",
|
||||
name: "E-Mail",
|
||||
link_url: "https://example.com/email",
|
||||
target: "app1",
|
||||
},
|
||||
{
|
||||
icon_uri: "http://localhost:8080/app2.png",
|
||||
name: "Riot",
|
||||
link_url: "https://riot.im/app",
|
||||
target: "riot-im",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Links",
|
||||
links: [
|
||||
{
|
||||
icon_uri: "http://localhost:8080/app1.png",
|
||||
name: "Link",
|
||||
link_url: "https://example.com/link1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
logo_url: "http://localhost:8080/opendesk/logomark.svg",
|
||||
logo_link_url: "https://example.com/portal",
|
||||
menu: {
|
||||
type: "univention",
|
||||
logo_url: "http://localhost:8080/opendesk/logofull.svg",
|
||||
ics_url: "http://localhost:8080/ics/",
|
||||
},
|
||||
theme: {
|
||||
triggerBackgroundColorHover: "#571EFA",
|
||||
triggerBackgroundColorPressed: "#4519C2",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const config of configs) {
|
||||
const type = config.menu.type;
|
||||
|
||||
test.describe(`${type} config`, () => {
|
||||
test.use({
|
||||
config: {
|
||||
"io.element.element-web-modules.banner": config,
|
||||
},
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context, moduleDir, navigationJsonResolver }) => {
|
||||
await context.route("http://localhost:8080/ics/navigation.json*", async (route) => {
|
||||
await navigationJsonResolver.promise;
|
||||
await route.fulfill({
|
||||
path: `${moduleDir}/e2e/fixture/navigation.json`,
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
await context.route("http://localhost:8080/ics/silent", async (route) => {
|
||||
await route.fulfill({
|
||||
path: `${moduleDir}/e2e/fixture/silent/index.html`,
|
||||
contentType: "text/html",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should render", { tag: ["@screenshot"] }, async ({ page, axe, navigationJsonResolver }) => {
|
||||
await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible();
|
||||
await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal");
|
||||
|
||||
const nav = page.locator("nav");
|
||||
|
||||
const trigger = page.getByLabel("Show menu");
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
// Assert the banner looks as we expect
|
||||
await expect(nav).toMatchAriaSnapshot();
|
||||
await expect(nav).toMatchScreenshot(`${type}_nav.png`);
|
||||
|
||||
// Check hover styles
|
||||
await trigger.hover();
|
||||
await expect(nav).toMatchScreenshot(`${type}_nav_hover.png`);
|
||||
|
||||
await test.step("open menu", async () => {
|
||||
await trigger.click();
|
||||
const sidebar = page.getByRole("dialog");
|
||||
|
||||
if (type === "univention") {
|
||||
await expect(sidebar).toMatchScreenshot(`${type}_menu_loading.png`);
|
||||
navigationJsonResolver.resolve();
|
||||
}
|
||||
await page.pause();
|
||||
|
||||
const emailApp = page.getByText("E-Mail");
|
||||
await expect(emailApp).toHaveAttribute("href", "https://example.com/email");
|
||||
await emailApp.hover();
|
||||
|
||||
// Assert the sidebar looks as we expect
|
||||
await expect(axe).toHaveNoViolations();
|
||||
await expect(sidebar).toMatchAriaSnapshot();
|
||||
await expect(page).toMatchScreenshot(`${type}_menu.png`, {
|
||||
// We exclude this as we don't want to assert Element's styling, only our own
|
||||
css: `
|
||||
#matrixchat {
|
||||
opacity: 0;
|
||||
background: orchid;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Verify that the #matrixchat root got shrunk to fit rather than exploding the viewport
|
||||
const bodyBoundingBox = await page.locator("body").boundingBox();
|
||||
const rootBoundingBox = await page.locator("#matrixchat").boundingBox();
|
||||
const headerBoundingBox = await page.getByTestId("banner").boundingBox();
|
||||
expect(rootBoundingBox!.height).toBeLessThan(bodyBoundingBox!.height);
|
||||
expect(rootBoundingBox!.height + headerBoundingBox!.height).toEqual(bodyBoundingBox!.height);
|
||||
});
|
||||
|
||||
await test.step("close menu", async () => {
|
||||
await expect(axe).toHaveNoViolations();
|
||||
|
||||
// Assert it closes by clicking the overlay
|
||||
await page.getByTestId("dialog-overlay").click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("univention config", () => {
|
||||
test.use({
|
||||
config: {
|
||||
"io.element.element-web-modules.banner": {
|
||||
logo_url: "http://localhost:8080/opendesk/logomark.svg",
|
||||
logo_link_url: "https://example.com/portal",
|
||||
menu: {
|
||||
type: "univention",
|
||||
logo_url: "http://localhost:8080/opendesk/logofull.svg",
|
||||
ics_url: "http://localhost:8080/ics/",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.route("http://localhost:8080/ics/silent", async (route) => {
|
||||
await route.fulfill({ status: 500 });
|
||||
});
|
||||
await context.route("http://localhost:8080/ics/navigation.json*", async (route) => {
|
||||
await route.fulfill({ status: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
test("should render error", { tag: ["@screenshot"] }, async ({ page, axe }) => {
|
||||
await expect(page.getByRole("heading", { name: "Be in your element" })).toBeVisible();
|
||||
await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal");
|
||||
|
||||
const trigger = page.getByLabel("Show menu");
|
||||
|
||||
await trigger.click();
|
||||
const sidebar = page.getByRole("dialog");
|
||||
await expect(sidebar.getByText("Failed to load")).toBeVisible();
|
||||
await expect(sidebar).toMatchScreenshot("univention_error.png");
|
||||
await expect(axe).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 14576) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px"
|
||||
width="789.322px" height="336.807px" viewBox="0 0 789.322 336.807" enable-background="new 0 0 789.322 336.807"
|
||||
xml:space="preserve">
|
||||
<path d="M8.876,7.71v321.386h23.13v7.711H0V0h32.006v7.71H8.876z"/>
|
||||
<path d="M97.989,109.594v16.264h0.463c4.338-6.191,9.563-10.998,15.684-14.406c6.117-3.402,13.129-5.11,21.027-5.11
|
||||
c7.588,0,14.521,1.475,20.793,4.415c6.274,2.945,11.038,8.131,14.291,15.567c3.56-5.265,8.4-9.913,14.521-13.94
|
||||
c6.117-4.025,13.358-6.042,21.724-6.042c6.351,0,12.234,0.776,17.66,2.325c5.418,1.549,10.065,4.027,13.938,7.434
|
||||
c3.869,3.41,6.889,7.863,9.062,13.357c2.167,5.504,3.253,12.122,3.253,19.869v80.385h-32.993v-68.074
|
||||
c0-4.025-0.154-7.82-0.465-11.385c-0.313-3.56-1.161-6.656-2.555-9.293c-1.395-2.631-3.45-4.724-6.157-6.274
|
||||
c-2.711-1.543-6.391-2.322-11.037-2.322s-8.403,0.896-11.269,2.671c-2.868,1.784-5.112,4.109-6.737,6.971
|
||||
c-1.626,2.869-2.711,6.12-3.252,9.762c-0.545,3.638-0.814,7.318-0.814,11.035v66.91h-32.991v-67.375c0-3.562-0.081-7.087-0.23-10.57
|
||||
c-0.158-3.487-0.814-6.7-1.978-9.645c-1.162-2.94-3.099-5.304-5.809-7.088c-2.711-1.775-6.699-2.671-11.965-2.671
|
||||
c-1.551,0-3.603,0.349-6.156,1.048c-2.556,0.697-5.036,2.016-7.435,3.949c-2.404,1.938-4.454,4.726-6.158,8.363
|
||||
c-1.705,3.642-2.556,8.402-2.556,14.287v69.701h-32.99V109.594H97.989z"/>
|
||||
<path d="M271.545,127.254c3.405-5.113,7.744-9.215,13.012-12.316c5.264-3.097,11.186-5.303,17.771-6.621
|
||||
c6.582-1.315,13.205-1.976,19.865-1.976c6.042,0,12.158,0.428,18.354,1.277c6.195,0.855,11.85,2.522,16.962,4.997
|
||||
c5.111,2.477,9.292,5.926,12.546,10.338c3.253,4.414,4.879,10.262,4.879,17.543v62.494c0,5.428,0.31,10.611,0.931,15.567
|
||||
c0.615,4.959,1.701,8.676,3.251,11.153h-33.455c-0.621-1.86-1.126-3.755-1.511-5.693c-0.39-1.933-0.661-3.908-0.813-5.923
|
||||
c-5.267,5.422-11.465,9.217-18.585,11.386c-7.127,2.163-14.407,3.251-21.842,3.251c-5.733,0-11.077-0.698-16.033-2.09
|
||||
c-4.958-1.395-9.293-3.562-13.01-6.51c-3.718-2.938-6.622-6.656-8.713-11.147s-3.138-9.84-3.138-16.033
|
||||
c0-6.813,1.199-12.43,3.604-16.84c2.399-4.417,5.495-7.939,9.295-10.575c3.793-2.632,8.129-4.607,13.01-5.923
|
||||
c4.878-1.315,9.795-2.358,14.752-3.137c4.957-0.772,9.835-1.393,14.638-1.857c4.801-0.466,9.062-1.164,12.779-2.093
|
||||
c3.718-0.929,6.658-2.282,8.829-4.065c2.165-1.781,3.172-4.375,3.02-7.785c0-3.56-0.58-6.389-1.742-8.479
|
||||
c-1.161-2.09-2.711-3.719-4.646-4.88c-1.937-1.161-4.183-1.936-6.737-2.325c-2.557-0.382-5.309-0.58-8.248-0.58
|
||||
c-6.506,0-11.617,1.395-15.335,4.183c-3.716,2.788-5.889,7.437-6.506,13.94h-32.991C266.2,138.793,268.133,132.362,271.545,127.254z
|
||||
M336.714,173.837c-2.09,0.696-4.337,1.275-6.736,1.741c-2.402,0.465-4.918,0.853-7.551,1.161c-2.635,0.313-5.268,0.698-7.899,1.163
|
||||
c-2.48,0.461-4.919,1.086-7.317,1.857c-2.404,0.779-4.495,1.822-6.274,3.138c-1.784,1.317-3.216,2.985-4.3,4.994
|
||||
c-1.085,2.014-1.626,4.571-1.626,7.668c0,2.94,0.541,5.422,1.626,7.431c1.084,2.017,2.558,3.604,4.416,4.765
|
||||
s4.025,1.976,6.507,2.438c2.475,0.466,5.031,0.698,7.665,0.698c6.505,0,11.537-1.082,15.103-3.253
|
||||
c3.561-2.166,6.192-4.762,7.899-7.785c1.702-3.019,2.749-6.072,3.137-9.174c0.384-3.097,0.58-5.576,0.58-7.434V170.93
|
||||
C340.548,172.172,338.806,173.139,336.714,173.837z"/>
|
||||
<path d="M461.826,109.594v22.072h-24.161v59.479c0,5.573,0.928,9.292,2.788,11.149c1.856,1.859,5.576,2.788,11.152,2.788
|
||||
c1.859,0,3.638-0.076,5.343-0.232c1.703-0.152,3.33-0.388,4.878-0.696v25.557c-2.788,0.465-5.887,0.773-9.293,0.931
|
||||
c-3.407,0.149-6.737,0.23-9.99,0.23c-5.111,0-9.953-0.35-14.521-1.048c-4.571-0.695-8.597-2.047-12.081-4.063
|
||||
c-3.486-2.011-6.236-4.88-8.248-8.597c-2.016-3.714-3.021-8.595-3.021-14.639v-70.859h-19.98v-22.072h19.98V73.582h32.992v36.012
|
||||
H461.826z"/>
|
||||
<path d="M508.989,109.594v22.306h0.465c1.546-3.72,3.636-7.163,6.272-10.341c2.634-3.172,5.652-5.885,9.06-8.131
|
||||
c3.405-2.242,7.047-3.985,10.923-5.228c3.868-1.237,7.898-1.859,12.081-1.859c2.168,0,4.566,0.39,7.202,1.163v30.67
|
||||
c-1.551-0.312-3.41-0.584-5.576-0.814c-2.17-0.233-4.26-0.35-6.274-0.35c-6.041,0-11.152,1.01-15.332,3.021
|
||||
c-4.182,2.014-7.55,4.761-10.107,8.247c-2.555,3.487-4.379,7.55-5.462,12.198c-1.083,4.645-1.625,9.682-1.625,15.102v54.133h-32.991
|
||||
V109.594H508.989z"/>
|
||||
<path d="M568.931,91.006V63.823h32.994v27.183H568.931z M601.925,109.594v120.117h-32.994V109.594H601.925z"/>
|
||||
<path d="M619.116,109.594h37.637l21.144,31.365l20.911-31.365h36.476l-39.496,56.226l44.377,63.892h-37.64l-25.093-37.87
|
||||
l-25.094,37.87H615.4l43.213-63.193L619.116,109.594z"/>
|
||||
<path d="M780.444,329.096V7.71h-23.13V0h32.008v336.807h-32.008v-7.711H780.444z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"identifier": "applications",
|
||||
"display_name": "Anwendungen",
|
||||
"entries": [
|
||||
{
|
||||
"identifier": "app1",
|
||||
"display_name": "Portal",
|
||||
"icon_url": "http://localhost:8080/opendesk/app1.svg",
|
||||
"link": "https://example.com/app1",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app2",
|
||||
"display_name": "Aufgaben",
|
||||
"icon_url": "http://localhost:8080/opendesk/app2.svg",
|
||||
"link": "https://example.com/app2",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app3",
|
||||
"display_name": "Chat",
|
||||
"icon_url": "http://localhost:8080/opendesk/app3.svg",
|
||||
"link": "https://example.com/app3",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app4",
|
||||
"display_name": "Dateien",
|
||||
"icon_url": "http://localhost:8080/opendesk/app4.svg",
|
||||
"link": "https://example.com/ap4",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app5",
|
||||
"display_name": "E-Mail",
|
||||
"icon_url": "http://localhost:8080/opendesk/app5.svg",
|
||||
"link": "https://example.com/email",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app6",
|
||||
"display_name": "Kelender",
|
||||
"icon_url": "http://localhost:8080/opendesk/app6.svg",
|
||||
"link": "https://example.com/app6",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app7",
|
||||
"display_name": "Kontakte",
|
||||
"icon_url": "http://localhost:8080/opendesk/app7.svg",
|
||||
"link": "https://example.com/app7",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app8",
|
||||
"display_name": "Notizen",
|
||||
"icon_url": "http://localhost:8080/opendesk/app8.svg",
|
||||
"link": "https://example.com/app8",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app9",
|
||||
"display_name": "Projekte",
|
||||
"icon_url": "http://localhost:8080/opendesk/app9.svg",
|
||||
"link": "https://example.com/app9",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app10",
|
||||
"display_name": "Videokonferenz",
|
||||
"icon_url": "http://localhost:8080/opendesk/app10.svg",
|
||||
"link": "https://example.com/app10",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"identifier": "app11",
|
||||
"display_name": "Wiki",
|
||||
"icon_url": "http://localhost:8080/opendesk/app11.svg",
|
||||
"link": "https://example.com/app11",
|
||||
"target": "_blank"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.0001 5.96045L6.48535 10.9613V18.4604H10.1625V13.7727H13.8397V18.4604H17.5148V10.9613L12.0001 5.96045Z" fill="#571EFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.8718 7.93982H7.49831C6.61769 7.93982 5.90381 8.6537 5.90381 9.53432V15.9078C5.90381 16.7884 6.61769 17.5023 7.49831 17.5023H13.8718C14.7524 17.5023 15.4663 16.7884 15.4663 15.9078V9.53432C15.4663 8.6537 14.7524 7.93982 13.8718 7.93982Z" fill="#571EFA"/>
|
||||
<path d="M20.5666 16.2273C18.6303 16.2273 17.0605 14.6575 17.0605 12.7212C17.0605 10.7848 18.6303 9.21509 20.5666 9.21509V16.2273Z" fill="#341291"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 519 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.8906 16.7064C14.8906 18.0265 13.8201 19.097 12.5 19.097C11.1799 19.097 10.1094 18.0265 10.1094 16.7064V15.9103H14.8906V16.7064Z" fill="#341291"/>
|
||||
<path d="M12.5 6.3457C9.85974 6.3457 7.71875 8.4867 7.71875 11.127C7.71875 12.3526 8.18428 13.4681 8.94442 14.3137H16.0556C16.8157 13.4681 17.2812 12.3526 17.2812 11.127C17.2812 8.4867 15.1403 6.3457 12.5 6.3457Z" fill="#571EFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.60352 12.0848C7.92382 12.0848 8.99414 11.0145 8.99414 9.69421C8.99414 8.37391 7.92382 7.30359 6.60352 7.30359C5.28321 7.30359 4.21289 8.37391 4.21289 9.69421C4.21289 11.0145 5.28321 12.0848 6.60352 12.0848Z" fill="#571EFA"/>
|
||||
<path d="M20.1509 8.89624H10.5884V10.4907H20.1509V8.89624Z" fill="#341291"/>
|
||||
<path d="M6.60352 18.1395C7.92382 18.1395 8.99414 17.0692 8.99414 15.7489C8.99414 14.4286 7.92382 13.3583 6.60352 13.3583C5.28321 13.3583 4.21289 14.4286 4.21289 15.7489C4.21289 17.0692 5.28321 18.1395 6.60352 18.1395Z" fill="#571EFA"/>
|
||||
<path d="M20.1509 14.9509H10.5884V16.5454H20.1509V14.9509Z" fill="#341291"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.1368 11.1263C14.0161 11.1263 14.7313 10.4134 14.7313 9.53177V7.93727C14.7313 7.05794 14.0183 6.34277 13.1368 6.34277H6.761C5.88167 6.34277 5.1665 7.05569 5.1665 7.93727V14.313L8.35325 11.1263H13.1345H13.1368Z" fill="#341291"/>
|
||||
<path d="M18.2373 12.7206H11.8616C10.9823 12.7206 10.2671 13.4335 10.2671 14.3151V15.9096C10.2671 16.7889 10.98 17.5041 11.8616 17.5041H16.6428L19.8296 20.6908V14.3151C19.8296 13.4357 19.1167 12.7206 18.2351 12.7206H18.2373Z" fill="#571EFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 586 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.06592 10.2686H17.2224C18.1017 10.2686 18.8168 10.9837 18.8168 11.863V16.644C18.8168 17.5233 18.1017 18.2384 17.2224 18.2384H7.66034C6.78105 18.2384 6.06592 17.5233 6.06592 16.644V10.2686Z" fill="#571EFA"/>
|
||||
<path d="M11.4834 8.67372H6.06592V7.71796C6.06592 6.83642 6.7788 6.12354 7.66034 6.12354H10.2105L11.4856 8.67372H11.4834Z" fill="#341291"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5388 12.7107L18.9609 7.89411C18.6516 7.61951 18.2459 7.45129 17.7982 7.45129H7.2794C6.83411 7.45129 6.42593 7.61951 6.1167 7.89411L12.5388 12.7107Z" fill="#571EFA"/>
|
||||
<path d="M14.0008 13.8069L12.5387 14.9028L11.0767 13.8069L5.52539 9.64343V16.2164C5.52539 17.1837 6.3096 17.9704 7.27934 17.9704H17.7981C18.7654 17.9704 19.552 17.1862 19.552 16.2164V9.64343L14.0008 13.8069Z" fill="#341291"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
@@ -0,0 +1,7 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.67479 13.6771H4.21289V18.4584H8.67479V13.6771Z" fill="#571EFA"/>
|
||||
<path d="M14.7309 13.6771H10.269V18.4584H14.7309V13.6771Z" fill="#571EFA"/>
|
||||
<path d="M8.67479 7.30359H4.21289V12.0848H8.67479V7.30359Z" fill="#341291"/>
|
||||
<path d="M14.7309 7.30359H10.269V12.0848H14.7309V7.30359Z" fill="#571EFA"/>
|
||||
<path d="M20.7876 7.30359H16.3257V12.0848H20.7876V7.30359Z" fill="#571EFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.03809 12.4022C9.35839 12.4022 10.4287 11.3319 10.4287 10.0116C10.4287 8.69129 9.35839 7.62097 8.03809 7.62097C6.71778 7.62097 5.64746 8.69129 5.64746 10.0116C5.64746 11.3319 6.71778 12.4022 8.03809 12.4022Z" fill="#571EFA"/>
|
||||
<path d="M16.9619 12.4022C18.2822 12.4022 19.3525 11.3319 19.3525 10.0116C19.3525 8.69129 18.2822 7.62097 16.9619 7.62097C15.6416 7.62097 14.5713 8.69129 14.5713 10.0116C14.5713 11.3319 15.6416 12.4022 16.9619 12.4022Z" fill="#341291"/>
|
||||
<path d="M4.21289 17.8214C4.21289 15.7097 5.92434 13.996 8.03834 13.996C10.1523 13.996 11.8638 15.7074 11.8638 17.8214H4.21289Z" fill="#571EFA"/>
|
||||
<path d="M13.1367 17.8214C13.1367 15.7097 14.8482 13.996 16.9622 13.996C19.0762 13.996 20.7876 15.7074 20.7876 17.8214H13.1367Z" fill="#341291"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 869 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.2369 17.5029H6.79199V19.0974H18.2369V17.5029Z" fill="#341291"/>
|
||||
<path d="M14.7599 6.78423L15.8867 7.91095C16.5096 8.53391 16.5096 9.54144 15.8867 10.1644L14.6902 11.3608L10.1429 15.9082H6.7627V12.528L11.3101 7.98067L12.5065 6.78423C13.1294 6.16128 14.137 6.16128 14.7599 6.78423Z" fill="#571EFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.12451 19.095L6.12451 6.3457H4.53001L4.53001 19.095H6.12451Z" fill="#571EFA"/>
|
||||
<path d="M18.9089 13.5161H12.5354C11.6548 13.5161 10.9409 14.23 10.9409 15.1106V16.7029C10.9409 17.5835 11.6548 18.2974 12.5354 18.2974H18.9089C19.7895 18.2974 20.5034 17.5835 20.5034 16.7029V15.1106C20.5034 14.23 19.7895 13.5161 18.9089 13.5161Z" fill="#571EFA"/>
|
||||
<path d="M14.1277 7.1427H9.31276C8.43214 7.1427 7.71826 7.85658 7.71826 8.7372V10.3295C7.71826 11.2101 8.43214 11.924 9.31276 11.924H14.1277C15.0084 11.924 15.7222 11.2101 15.7222 10.3295V8.7372C15.7222 7.85658 15.0084 7.1427 14.1277 7.1427Z" fill="#341291"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 718 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="113" height="31" viewBox="0 0 113 31" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="101" height="31" fill="white"/>
|
||||
<path d="M20.7634 21.3042V21.6881C20.7634 23.3086 19.4467 24.6263 17.8274 24.6263H10.936C9.31669 24.6263 8 23.3086 8 21.6881V14.7914C8 13.1708 9.31669 11.8531 10.936 11.8531H11.3196V21.306H20.7652L20.7634 21.3042Z" fill="#927AFA"/>
|
||||
<path d="M20.8913 19.0041H13.6162V10.9555C13.6162 8.20836 15.8497 5.97314 18.5948 5.97314H20.8931C24.0597 5.97314 26.6372 8.55266 26.6372 11.7216V13.2538C26.6372 16.4228 24.0597 19.0023 20.8931 19.0023L20.8913 19.0041ZM16.934 15.6819H20.8913C22.2278 15.6819 23.3157 14.5932 23.3157 13.2556V11.7234C23.3157 10.3859 22.2278 9.29713 20.8913 9.29713H18.593C17.6779 9.29713 16.934 10.0416 16.934 10.9573V15.6837V15.6819Z" fill="#571EFA"/>
|
||||
<path d="M36.0164 12.3705C38.6912 12.3705 40.4006 14.3353 40.4006 16.7418C40.4006 19.1482 38.6912 21.1131 36.0164 21.1131C33.3416 21.1131 31.6143 19.1482 31.6143 16.7418C31.6143 14.3353 33.3236 12.3705 36.0164 12.3705ZM36.0164 20.0459C38.0644 20.0459 39.1487 18.5209 39.1487 16.7418C39.1487 14.9626 38.0644 13.4376 36.0164 13.4376C33.9684 13.4376 32.8679 14.9626 32.8679 16.7418C32.8679 18.5209 33.9504 20.0459 36.0164 20.0459Z" fill="black"/>
|
||||
<path d="M42.0273 12.5399H43.281V14.6742C43.8736 13.2682 45.1272 12.3705 46.9555 12.3705C49.3096 12.3705 50.9001 14.1496 50.9001 16.758C50.9001 19.3664 49.3096 21.1131 46.9555 21.1131C45.093 21.1131 43.8574 20.1811 43.281 18.8094V24.0279H42.0273V12.5399ZM46.5646 20.0459C48.2577 20.0459 49.6465 18.995 49.6465 16.758C49.6465 14.521 48.2577 13.4376 46.5646 13.4376C44.8715 13.4376 43.3314 14.539 43.3314 16.758C43.3314 18.977 44.8895 20.0459 46.5646 20.0459Z" fill="black"/>
|
||||
<path d="M56.2153 12.3706C58.6199 12.3706 60.2284 13.8793 60.2284 16.4535V16.9618H53.4739C53.5243 18.7247 54.4213 20.0785 56.2838 20.0785C57.8238 20.0785 58.7226 19.2655 59.027 18.0109H60.2626C59.9078 19.5016 58.8919 21.1114 56.318 21.1114C53.5927 21.1114 52.2725 19.078 52.2725 16.6211C52.2725 13.91 53.8467 12.3688 56.2171 12.3688L56.2153 12.3706ZM58.991 15.9794C58.8721 14.3354 57.7716 13.4035 56.1973 13.4035C54.7239 13.4035 53.6234 14.3516 53.4883 15.9794H58.991Z" fill="black"/>
|
||||
<path d="M61.9883 12.5399H63.2419V14.8779C63.6994 13.3186 64.8846 12.3705 66.7291 12.3705C68.5735 12.3705 69.7425 13.5908 69.7425 15.7936V20.9436H68.4727V15.8946C68.4727 14.1496 67.7954 13.4376 66.3058 13.4376C64.4091 13.4376 63.2419 15.0311 63.2419 17.2844V20.9436H61.9883V12.5399Z" fill="black"/>
|
||||
<path d="M71.8589 8.9978H76.2448C77.4859 8.9978 78.581 9.15102 79.5303 9.45566C80.4777 9.7603 81.254 10.3534 81.8574 11.2348C82.4608 12.1163 82.7634 13.3583 82.7634 14.9626C82.7634 16.5669 82.4608 17.8269 81.8574 18.7066C81.254 19.588 80.4777 20.1811 79.5303 20.4857C78.581 20.7904 77.4877 20.9436 76.2448 20.9436H71.8589V8.9978ZM76.2448 18.5552C77.1022 18.5552 77.7885 18.4867 78.3018 18.3515C78.8152 18.2163 79.2331 17.8918 79.5555 17.3763C79.8779 16.8625 80.0382 16.0568 80.0382 14.9626C80.0382 13.8684 79.8743 13.0771 79.5483 12.5561C79.2204 12.037 78.8026 11.7089 78.2946 11.5737C77.7867 11.4385 77.104 11.37 76.2466 11.37H74.5697V18.5552H76.2466H76.2448Z" fill="black"/>
|
||||
<path d="M84.5322 14.2759C84.9105 13.6143 85.4346 13.1096 86.1065 12.7599C86.7783 12.4102 87.5493 12.2354 88.4174 12.2354C89.8404 12.2354 90.9518 12.6139 91.7533 13.371C92.5548 14.1281 92.9547 15.1736 92.9547 16.5057V17.2177H86.555C86.6 17.827 86.7928 18.3047 87.1314 18.649C87.47 18.9933 87.9329 19.1663 88.5201 19.1663C89.0281 19.1663 89.4477 19.06 89.781 18.8437C90.1142 18.6292 90.3249 18.3245 90.4168 17.9279H92.9727C92.8376 18.8761 92.3819 19.6458 91.6092 20.2317C90.8365 20.8193 89.8116 21.1131 88.5363 21.1131C87.5997 21.1131 86.7873 20.9239 86.0975 20.5453C85.4094 20.1668 84.8817 19.6386 84.5142 18.9608C84.1468 18.2831 83.9648 17.5043 83.9648 16.6229C83.9648 15.7414 84.154 14.9374 84.5322 14.2759ZM90.4492 15.8261C90.4042 15.2835 90.2042 14.8653 89.8476 14.5715C89.4928 14.2777 89.0317 14.1317 88.4679 14.1317C87.9041 14.1317 87.4664 14.2795 87.1224 14.5715C86.7783 14.8653 86.5838 15.2835 86.5388 15.8261H90.4492Z" fill="black"/>
|
||||
<path d="M95.2406 20.2821C94.4949 19.7287 94.1221 18.9157 94.1221 17.8414H96.696C96.696 18.3281 96.8491 18.6868 97.1535 18.9175C97.4579 19.15 97.9262 19.2654 98.5585 19.2654C99.0214 19.2654 99.3564 19.2059 99.5653 19.087C99.7743 18.968 99.8788 18.7787 99.8788 18.5191C99.8788 18.3389 99.8175 18.1911 99.6932 18.0793C99.569 17.9657 99.3708 17.8702 99.1006 17.7909L96.4925 17.0789C95.9161 16.9329 95.4207 16.6697 95.0029 16.2911C94.585 15.9126 94.376 15.3898 94.376 14.7229C94.376 13.9207 94.7057 13.306 95.3667 12.8752C96.0278 12.4462 96.95 12.2317 98.1352 12.2317C99.4447 12.2317 100.466 12.4949 101.199 13.0194C101.932 13.544 102.3 14.3047 102.3 15.2979H99.7257C99.7257 14.4741 99.2015 14.0613 98.1514 14.0613C97.7785 14.0613 97.4849 14.1244 97.2706 14.247C97.0563 14.3714 96.9482 14.5354 96.9482 14.7391C96.9482 15.0888 97.258 15.343 97.8794 15.5016L99.877 15.9937C100.655 16.1866 101.28 16.4822 101.748 16.8842C102.217 17.2844 102.451 17.8468 102.451 18.5696C102.451 19.3952 102.121 20.0243 101.46 20.4587C100.799 20.8932 99.8211 21.1113 98.5224 21.1113C97.0779 21.1113 95.9827 20.8337 95.237 20.2803L95.2406 20.2821Z" fill="black"/>
|
||||
<path d="M103.875 8.4895H106.449V15.7071L109.852 12.4029H113L108.751 16.5543L112.984 20.9436H109.852L106.449 17.3511V20.9436H103.875V8.4895Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="35" height="36" viewBox="0 0 35 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.6929 25.8326V26.3232C21.6929 28.394 20.0294 30.0778 17.9837 30.0778H9.27732C7.23157 30.0778 5.56812 28.394 5.56812 26.3232V17.5102C5.56812 15.4394 7.23157 13.7556 9.27732 13.7556H9.76201V25.8349H21.6952L21.6929 25.8326Z" fill="#AA98FB"/>
|
||||
<path d="M21.8544 22.8933H12.6633V12.6084C12.6633 9.09797 15.4851 6.2417 18.953 6.2417H21.8567C25.8572 6.2417 29.1135 9.53793 29.1135 13.5874V15.5453C29.1135 19.5948 25.8572 22.891 21.8567 22.891L21.8544 22.8933ZM16.855 18.6481H21.8544C23.5429 18.6481 24.9173 17.2568 24.9173 15.5476V13.5897C24.9173 11.8805 23.5429 10.4893 21.8544 10.4893H18.9508C17.7948 10.4893 16.855 11.4406 16.855 12.6107V18.6504V18.6481Z" fill="#571EFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 783 B |
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Univention Silent Login</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
- navigation:
|
||||
- button "Show menu":
|
||||
- img
|
||||
- link "Show portal":
|
||||
- /url: https://example.com/portal
|
||||
- img "Portal logo"
|
||||
@@ -0,0 +1,13 @@
|
||||
- dialog "Portal logo Close menu":
|
||||
- heading "Portal logo Close menu" [level=2]:
|
||||
- img "Portal logo"
|
||||
- button "Close menu":
|
||||
- img
|
||||
- heading "Applications" [level=2]
|
||||
- link "E-Mail":
|
||||
- /url: https://example.com/email
|
||||
- link "Riot":
|
||||
- /url: https://riot.im/app
|
||||
- heading "Links" [level=2]
|
||||
- link "Link":
|
||||
- /url: https://example.com/link1
|
||||
@@ -0,0 +1,6 @@
|
||||
- navigation:
|
||||
- button "Show menu":
|
||||
- img
|
||||
- link "Show portal":
|
||||
- /url: https://example.com/portal
|
||||
- img "Portal logo"
|
||||
@@ -0,0 +1,28 @@
|
||||
- dialog "Portal logo Close menu":
|
||||
- heading "Portal logo Close menu" [level=2]:
|
||||
- img "Portal logo"
|
||||
- button "Close menu":
|
||||
- img
|
||||
- heading "Anwendungen" [level=2]
|
||||
- link "Portal":
|
||||
- /url: https://example.com/app1
|
||||
- link "Aufgaben":
|
||||
- /url: https://example.com/app2
|
||||
- link "Chat":
|
||||
- /url: https://example.com/app3
|
||||
- link "Dateien":
|
||||
- /url: https://example.com/ap4
|
||||
- link "E-Mail":
|
||||
- /url: https://example.com/email
|
||||
- link "Kelender":
|
||||
- /url: https://example.com/app6
|
||||
- link "Kontakte":
|
||||
- /url: https://example.com/app7
|
||||
- link "Notizen":
|
||||
- /url: https://example.com/app8
|
||||
- link "Projekte":
|
||||
- /url: https://example.com/app9
|
||||
- link "Videokonferenz":
|
||||
- /url: https://example.com/app10
|
||||
- link "Wiki":
|
||||
- /url: https://example.com/app11
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@element-hq/element-web-module-banner",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "pnpm lint:types && pnpm lint:js",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:js": "eslint --max-warnings 0 src -c ../.eslintrc.cjs",
|
||||
"test:playwright": "playwright test -c ../playwright.config.ts",
|
||||
"test:playwright:open": "yarn test:playwright -c ../playwright.config.ts --ui",
|
||||
"test:playwright:screenshots": "playwright-screenshots yarn test:playwright --update-snapshots --grep @screenshot"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
|
||||
"@element-hq/element-web-module-api": "workspace:*",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"matrix-web-i18n": "^3.6.0",
|
||||
"matrix-widget-api": "^1.17.0",
|
||||
"react": "catalog:",
|
||||
"rollup-plugin-external-globals": "^0.13.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-node-polyfills": "catalog:",
|
||||
"vite-plugin-svgr": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-web": "^9.0.0",
|
||||
"framer-motion": "^12.4.10",
|
||||
"styled-components": "^6.1.18",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC } from "react";
|
||||
import styled from "styled-components";
|
||||
import { type Api } from "@element-hq/element-web-module-api";
|
||||
import { Heading } from "@vector-im/compound-web";
|
||||
|
||||
import { type ModuleConfig } from "./config";
|
||||
import UniventionMenu from "./Univention/Menu";
|
||||
import Menu from "./Menu";
|
||||
import Logo from "./Logo.tsx";
|
||||
|
||||
const Root = styled.nav`
|
||||
height: ${({ theme }): string => theme.bannerHeight};
|
||||
background-color: ${({ theme }): string => theme.bannerBackgroundColor};
|
||||
border-bottom: "var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary)";
|
||||
display: flex;
|
||||
gap: var(--cpd-space-3x);
|
||||
|
||||
h1 {
|
||||
align-self: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const LogoContainer = styled.div`
|
||||
display: flex;
|
||||
padding: var(--cpd-space-3x) 0;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
logoUrl: string;
|
||||
href: string;
|
||||
menu: ModuleConfig["menu"];
|
||||
title: string;
|
||||
}
|
||||
|
||||
const Banner: FC<Props> = ({ api, logoUrl, href, menu, title }) => {
|
||||
let menuJsx;
|
||||
switch (menu.type) {
|
||||
case "static": {
|
||||
menuJsx = <Menu api={api} config={menu} fallbackLogoUrl={logoUrl} />;
|
||||
break;
|
||||
}
|
||||
case "univention": {
|
||||
menuJsx = <UniventionMenu api={api} config={menu} fallbackLogoUrl={logoUrl} />;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Root>
|
||||
{menuJsx}
|
||||
<LogoContainer>
|
||||
<Logo api={api} src={logoUrl} href={href} height="100%" />
|
||||
</LogoContainer>
|
||||
<Heading size="sm" weight="medium" as="h1">
|
||||
{title}
|
||||
</Heading>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Banner;
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC } from "react";
|
||||
import styled from "styled-components";
|
||||
import { type Api } from "@element-hq/element-web-module-api";
|
||||
|
||||
const Anchor = styled.a`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const Image = styled.img<{
|
||||
height?: string;
|
||||
}>`
|
||||
align-self: center;
|
||||
height: ${({ height }): string => height ?? "32px"};
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
src: string;
|
||||
height?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const Logo: FC<Props> = ({ api, src, href, height }) => {
|
||||
const img = <Image alt={api.i18n.translate("logo_alt")} src={src} height={height} />;
|
||||
|
||||
if (!href) return img;
|
||||
|
||||
return (
|
||||
<Anchor aria-label={api.i18n.translate("logo_link_label")} href={href}>
|
||||
{img}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, type JSX, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
import { type Api } from "@element-hq/element-web-module-api";
|
||||
|
||||
import { type StaticConfig } from "./config";
|
||||
import Logo from "./Logo.tsx";
|
||||
import TriggerIcon from "./trigger.svg?react";
|
||||
|
||||
const Sidebar = styled(motion.div)`
|
||||
padding: 0 var(--cpd-space-3x) var(--cpd-space-4x);
|
||||
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);
|
||||
overflow: auto;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: ${({ theme }): string => theme.menuWidth};
|
||||
background: ${({ theme }): string => theme.menuBackgroundColor};
|
||||
border-radius: 0 16px 16px 0;
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
||||
font-feature-settings: normal;
|
||||
`;
|
||||
|
||||
const SidebarHeading = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--cpd-space-5x);
|
||||
`;
|
||||
|
||||
const Trigger = styled.button`
|
||||
align-items: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
background-color: ${({ theme }): string => theme.triggerBackgroundColor};
|
||||
color: ${({ theme }): string => theme.triggerColor};
|
||||
width: ${({ theme }): string => theme.triggerWidth};
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: ${({ theme }): string => theme.triggerBackgroundColorHover};
|
||||
color: ${({ theme }): string => theme.triggerColorContrast};
|
||||
}
|
||||
|
||||
&:active,
|
||||
&[data-expanded="true"] {
|
||||
background-color: ${({ theme }): string => theme.triggerBackgroundColorPressed};
|
||||
color: ${({ theme }): string => theme.triggerColorContrast};
|
||||
}
|
||||
|
||||
svg {
|
||||
margin: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
/* Reset button styles */
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: ${({ theme }): string => theme.menuButtonBackgroundColorPressed};
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryHeading = styled.h2`
|
||||
line-height: var(--cpd-font-line-height-tight);
|
||||
letter-spacing: var(--cpd-font-letter-spacing-heading-lg);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: ${({ theme }): string => theme.subheadingColor};
|
||||
margin-top: var(--cpd-space-4x);
|
||||
margin-bottom: var(--cpd-space-2x);
|
||||
`;
|
||||
|
||||
const LinkButton = styled.a`
|
||||
font-size: 14px;
|
||||
color: var(--cpd-color-text-action-primary);
|
||||
font-weight: var(--cpd-font-weight-medium);
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
padding: var(--cpd-space-2x);
|
||||
align-items: center;
|
||||
|
||||
&:link {
|
||||
color: var(--cpd-color-text-action-primary);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: ${({ theme }): string => theme.menuButtonBackgroundColorPressed};
|
||||
}
|
||||
`;
|
||||
|
||||
const LinkLogo = styled.img`
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
border: ${({ theme }): string => `var(--cpd-border-width-1) solid ${theme.menuButtonBackgroundColorPressed}`};
|
||||
background-color: ${({ theme }): string => theme.menuBackgroundColor};
|
||||
margin-right: var(--cpd-space-2x);
|
||||
`;
|
||||
|
||||
const CentredContainer = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
|
||||
svg {
|
||||
margin: 0 auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const Overlay = styled(motion.div)`
|
||||
background-color: rgba(238, 239, 242, 0.5);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
config: StaticConfig | Error | null; // null for loading
|
||||
fallbackLogoUrl: string;
|
||||
}
|
||||
|
||||
const Category: FC<{
|
||||
data: StaticConfig["categories"][number];
|
||||
}> = ({ data }) => {
|
||||
return (
|
||||
<div>
|
||||
<CategoryHeading>{data.name}</CategoryHeading>
|
||||
{data.links.map((link) => (
|
||||
<LinkButton key={link.link_url} href={link.link_url} target={link.target ?? "_blank"}>
|
||||
<LinkLogo src={link.icon_uri} role="presentation" /> {link.name}
|
||||
</LinkButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
|
||||
const theme = useTheme();
|
||||
const width = parseInt(theme.menuWidth.slice(0, -2), 10);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
let content: JSX.Element;
|
||||
let logoUrl = fallbackLogoUrl;
|
||||
if (config instanceof Error) {
|
||||
content = <CentredContainer>{api.i18n.translate("univention_error")}</CentredContainer>;
|
||||
} else if (config) {
|
||||
content = (
|
||||
<>
|
||||
{config.categories.map((category) => (
|
||||
<Category key={category.name} data={category} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
if (config.logo_url) {
|
||||
logoUrl = config.logo_url;
|
||||
}
|
||||
} else {
|
||||
content = (
|
||||
<CentredContainer>
|
||||
<InlineSpinner size={32} />
|
||||
</CentredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<Trigger aria-haspopup={true} aria-expanded={open} aria-label={api.i18n.translate("trigger_label")}>
|
||||
<TriggerIcon />
|
||||
</Trigger>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog.Portal forceMount>
|
||||
<Dialog.Overlay asChild>
|
||||
<Overlay
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
data-testid="dialog-overlay"
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
<Dialog.Content asChild>
|
||||
<Sidebar
|
||||
initial={{ x: -width }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -width }}
|
||||
transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }}
|
||||
aria-label={api.i18n.translate("menu_label")}
|
||||
>
|
||||
<Dialog.Title>
|
||||
<SidebarHeading>
|
||||
<Logo api={api} src={logoUrl} />
|
||||
<Dialog.Close asChild>
|
||||
<CloseButton
|
||||
aria-label={api.i18n.translate("close_label")}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.04167 13.9999L6 12.9583L8.9375 9.99992L6 7.06242L7.04167 6.02075L10 8.95825L12.9375 6.02075L13.9792 7.06242L11.0417 9.99992L13.9792 12.9583L12.9375 13.9999L10 11.0624L7.04167 13.9999Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</CloseButton>
|
||||
</Dialog.Close>
|
||||
</SidebarHeading>
|
||||
</Dialog.Title>
|
||||
{content}
|
||||
</Sidebar>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { type Api } from "@element-hq/element-web-module-api";
|
||||
|
||||
import { type StaticConfig, type UniventionConfig } from "../config";
|
||||
import StaticMenu from "../Menu";
|
||||
import { fetchNavigation } from "./navigation";
|
||||
import SilentLogin from "./SilentLogin";
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
config: UniventionConfig;
|
||||
fallbackLogoUrl: string;
|
||||
}
|
||||
|
||||
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [data, setData] = useState<StaticConfig | Error>();
|
||||
const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en";
|
||||
|
||||
useEffect(() => {
|
||||
let discard = false;
|
||||
|
||||
setData(undefined);
|
||||
fetchNavigation(config.ics_url, language)
|
||||
.then((data) => {
|
||||
if (discard) return;
|
||||
setData(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (discard) return;
|
||||
setData(error);
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
discard = true;
|
||||
};
|
||||
}, [config, language, loggedIn]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loggedIn && <SilentLogin onLoggedIn={setLoggedIn} icsUrl={config.ics_url} />}
|
||||
<StaticMenu api={api} config={data ?? null} fallbackLogoUrl={config.logo_url ?? fallbackLogoUrl} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const HiddenIFrame = styled.iframe`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
icsUrl: string;
|
||||
onLoggedIn(success: boolean): void;
|
||||
}
|
||||
|
||||
const SilentLogin: FC<Props> = ({ onLoggedIn, icsUrl }) => {
|
||||
const url = new URL("silent", icsUrl);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: MessageEvent): void => {
|
||||
if (event.origin === url.origin && typeof event.data === "object" && event.data["loggedIn"] === true) {
|
||||
onLoggedIn(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", listener);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener("message", listener);
|
||||
};
|
||||
}, [onLoggedIn, url.origin]);
|
||||
|
||||
return <HiddenIFrame src={url.href} />;
|
||||
};
|
||||
|
||||
export default SilentLogin;
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { z } from "zod/mini";
|
||||
|
||||
import { type StaticConfig } from "../config";
|
||||
|
||||
/**
|
||||
* Univention Central Navigation API
|
||||
* https://docs.software-univention.de/nubus-kubernetes-customization/1.x/en/api/central-navigation.html
|
||||
*/
|
||||
const UniventionCentralNavigation = z.object({
|
||||
categories: z.array(
|
||||
z.object({
|
||||
identifier: z.string(),
|
||||
display_name: z.string(),
|
||||
entries: z.array(
|
||||
z.object({
|
||||
/**
|
||||
* A unique identifier for the navigation item.
|
||||
*/
|
||||
identifier: z.string(),
|
||||
/**
|
||||
* The URL to the icon in SVG format.
|
||||
*/
|
||||
icon_url: z.string(),
|
||||
/**
|
||||
* The label of the link.
|
||||
*/
|
||||
display_name: z.string(),
|
||||
/**
|
||||
* The destination URL of the link.
|
||||
*/
|
||||
link: z.string(),
|
||||
/**
|
||||
* The browsing context in which the browser opens the link.
|
||||
* Corresponds to the `target` property of `<a>` tags in HTML.
|
||||
*/
|
||||
target: z.string(),
|
||||
/**
|
||||
* It’s usually empty.
|
||||
*/
|
||||
keywords: z.optional(z.object({})),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type UniventionCentralNavigation = z.infer<typeof UniventionCentralNavigation>;
|
||||
|
||||
function navigationToConfig(navigation: UniventionCentralNavigation): StaticConfig {
|
||||
return {
|
||||
type: "static",
|
||||
categories: navigation.categories.map((category) => ({
|
||||
name: category.display_name,
|
||||
links: category.entries.map((entry) => ({
|
||||
icon_uri: entry.icon_url,
|
||||
name: entry.display_name,
|
||||
link_url: entry.link,
|
||||
target: entry.target,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchNavigation(icsUrl: string, language: string): Promise<StaticConfig> {
|
||||
const url = new URL("navigation.json", icsUrl);
|
||||
url.search = `?language=${language}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch navigation: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const config = await UniventionCentralNavigation.parseAsync(data);
|
||||
return navigationToConfig(config);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { z, type ZodMiniType, type input } from "zod/mini";
|
||||
|
||||
import { Theme } from "./theme.ts";
|
||||
|
||||
z.config(z.locales.en());
|
||||
|
||||
const StaticConfig = z.object({
|
||||
type: z.literal("static"),
|
||||
|
||||
/**
|
||||
* Alternative logo URL to display in the popover menu.
|
||||
* Will use the main logo url if omitted.
|
||||
*/
|
||||
logo_url: z.optional(z.url()),
|
||||
|
||||
/**
|
||||
* Categories of links to display in the menu.
|
||||
*/
|
||||
categories: z.array(
|
||||
z.object({
|
||||
/**
|
||||
* The category display name
|
||||
*/
|
||||
name: z.string(),
|
||||
/**
|
||||
* List of links to display in the category
|
||||
*/
|
||||
links: z.array(
|
||||
z.object({
|
||||
/**
|
||||
* The URL to the icon.
|
||||
*/
|
||||
icon_uri: z.url(),
|
||||
/**
|
||||
* The label of the link.
|
||||
*/
|
||||
name: z.string(),
|
||||
/**
|
||||
* The destination URL of the link.
|
||||
*/
|
||||
link_url: z.url(),
|
||||
/**
|
||||
* The browsing context in which the browser opens the link.
|
||||
*/
|
||||
target: z.optional(z.string()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type StaticConfig = z.infer<typeof StaticConfig>;
|
||||
|
||||
const UniventionConfig = z.object({
|
||||
type: z.literal("univention"),
|
||||
|
||||
/**
|
||||
* Alternative logo URL to display in the popover menu.
|
||||
* Will use the main logo url if omitted.
|
||||
*/
|
||||
logo_url: z.optional(z.url()),
|
||||
|
||||
/**
|
||||
* Base URL to an Intercom Service
|
||||
* https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints
|
||||
*/
|
||||
ics_url: z.url(),
|
||||
});
|
||||
|
||||
export type UniventionConfig = z.infer<typeof UniventionConfig>;
|
||||
|
||||
export const ModuleConfig = z.object({
|
||||
/**
|
||||
* The URL of the portal logo.svg file.
|
||||
* @example `https://example.com/logo.svg`
|
||||
*/
|
||||
logo_url: z.url(),
|
||||
|
||||
/**
|
||||
* The URL of the portal.
|
||||
* @example `https://example.com`
|
||||
*/
|
||||
logo_link_url: z.url(),
|
||||
|
||||
/**
|
||||
* The title to show to the right of the Logo
|
||||
*
|
||||
* Will fall back to `brand` variable if undefined, can be hidden by setting to the empty string.
|
||||
*/
|
||||
title: z.optional(z.string()),
|
||||
|
||||
/**
|
||||
* Configuration for the menu.
|
||||
*/
|
||||
menu: z.discriminatedUnion("type", [StaticConfig, UniventionConfig]),
|
||||
|
||||
/**
|
||||
* Theme variable overrides, optional.
|
||||
*/
|
||||
theme: z.prefault(Theme, {}),
|
||||
});
|
||||
|
||||
export type ModuleConfig = z.infer<typeof ModuleConfig>;
|
||||
|
||||
export type ConfigSchema = ZodMiniType<z.output<typeof ModuleConfig>, z.input<typeof ModuleConfig>>;
|
||||
|
||||
export const CONFIG_KEY = "io.element.element-web-modules.banner";
|
||||
|
||||
declare module "@element-hq/element-web-module-api" {
|
||||
export interface Config {
|
||||
[CONFIG_KEY]: input<ConfigSchema>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import compound from "@vector-im/compound-web/dist/style.css" with { type: "css" };
|
||||
|
||||
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
|
||||
import Translations from "./translations.json";
|
||||
import { ModuleConfig, CONFIG_KEY } from "./config";
|
||||
import Banner from "./Banner";
|
||||
import { name as ModuleName } from "../package.json";
|
||||
import style from "./style.css" with { type: "css" };
|
||||
|
||||
class BannerModule implements Module {
|
||||
public static readonly moduleApiVersion = "^1.0.0";
|
||||
|
||||
private config?: ModuleConfig;
|
||||
|
||||
public constructor(private api: Api) {}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
document.adoptedStyleSheets.push(compound);
|
||||
document.adoptedStyleSheets.push(style);
|
||||
|
||||
this.api.i18n.register(Translations);
|
||||
|
||||
try {
|
||||
this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY));
|
||||
} catch (e) {
|
||||
console.error("Failed to init module", e);
|
||||
throw new Error(`Errors in module configuration for "${ModuleName}"`);
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.dataset.testid = "banner";
|
||||
this.api.rootNode.before(div);
|
||||
|
||||
const root = this.api.createRoot(div);
|
||||
root.render(
|
||||
<ThemeProvider theme={this.config.theme}>
|
||||
<Banner
|
||||
api={this.api}
|
||||
logoUrl={this.config.logo_url}
|
||||
href={this.config.logo_link_url}
|
||||
menu={this.config.menu}
|
||||
title={this.config.title ?? this.api.config.get("brand")}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default BannerModule satisfies ModuleFactory;
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/* Styles to ensure the banner does not push the app out of the viewport */
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#matrixchat {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { z } from "zod/mini";
|
||||
|
||||
export const Theme = z.object({
|
||||
textColor: z.prefault(z.string(), "var(--cpd-color-text-primary)"),
|
||||
subheadingColor: z.prefault(z.string(), "var(--cpd-color-text-secondary)"),
|
||||
bannerBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-canvas-default)"),
|
||||
bannerHeight: z.prefault(z.string(), "60px"),
|
||||
triggerWidth: z.prefault(z.string(), "68px"),
|
||||
triggerBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-subtle-secondary)"),
|
||||
triggerBackgroundColorHover: z.prefault(z.string(), "var(--cpd-color-bg-accent-hovered)"),
|
||||
triggerBackgroundColorPressed: z.prefault(z.string(), "var(--cpd-color-bg-accent-pressed)"),
|
||||
triggerColor: z.prefault(z.string(), "var(--cpd-color-icon-primary)"),
|
||||
triggerColorContrast: z.prefault(z.string(), "var(--cpd-color-icon-on-solid-primary)"),
|
||||
menuWidth: z.prefault(z.string(), "320px"),
|
||||
menuBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-canvas-default)"),
|
||||
menuButtonBackgroundColorHover: z.prefault(z.string(), "var(--cpd-color-bg-action-secondary-hovered)"),
|
||||
menuButtonBackgroundColorPressed: z.prefault(z.string(), "var(--cpd-color-bg-action-secondary-pressed)"),
|
||||
});
|
||||
|
||||
export type Theme = z.infer<typeof Theme>;
|
||||
|
||||
declare module "styled-components" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface DefaultTheme extends Theme {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"logo_alt": {
|
||||
"en": "Portal logo",
|
||||
"de": "Portal Logo"
|
||||
},
|
||||
"menu_label": {
|
||||
"en": "Menu",
|
||||
"de": "Menü"
|
||||
},
|
||||
"trigger_label": {
|
||||
"en": "Show menu",
|
||||
"de": "Menü anzeigen"
|
||||
},
|
||||
"close_label": {
|
||||
"en": "Close menu",
|
||||
"de": "Menü schließen"
|
||||
},
|
||||
"logo_link_label": {
|
||||
"en": "Show portal",
|
||||
"de": "Portal anzeigen"
|
||||
},
|
||||
"univention_error": {
|
||||
"en": "Failed to load data from Univention",
|
||||
"de": "Daten konnten nicht von Univention geladen werden"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.99992 16.6667C4.54159 16.6667 4.14922 16.5035 3.82284 16.1771C3.49645 15.8507 3.33325 15.4584 3.33325 15C3.33325 14.5417 3.49645 14.1493 3.82284 13.823C4.14922 13.4966 4.54159 13.3334 4.99992 13.3334C5.45825 13.3334 5.85061 13.4966 6.177 13.823C6.50339 14.1493 6.66659 14.5417 6.66659 15C6.66659 15.4584 6.50339 15.8507 6.177 16.1771C5.85061 16.5035 5.45825 16.6667 4.99992 16.6667ZM9.99992 16.6667C9.54158 16.6667 9.14922 16.5035 8.82284 16.1771C8.49645 15.8507 8.33325 15.4584 8.33325 15C8.33325 14.5417 8.49645 14.1493 8.82284 13.823C9.14922 13.4966 9.54158 13.3334 9.99992 13.3334C10.4583 13.3334 10.8506 13.4966 11.177 13.823C11.5034 14.1493 11.6666 14.5417 11.6666 15C11.6666 15.4584 11.5034 15.8507 11.177 16.1771C10.8506 16.5035 10.4583 16.6667 9.99992 16.6667ZM14.9999 16.6667C14.5416 16.6667 14.1492 16.5035 13.8228 16.1771C13.4964 15.8507 13.3333 15.4584 13.3333 15C13.3333 14.5417 13.4964 14.1493 13.8228 13.823C14.1492 13.4966 14.5416 13.3334 14.9999 13.3334C15.4583 13.3334 15.8506 13.4966 16.177 13.823C16.5034 14.1493 16.6666 14.5417 16.6666 15C16.6666 15.4584 16.5034 15.8507 16.177 16.1771C15.8506 16.5035 15.4583 16.6667 14.9999 16.6667ZM4.99992 11.6667C4.54159 11.6667 4.14922 11.5035 3.82284 11.1771C3.49645 10.8507 3.33325 10.4584 3.33325 10C3.33325 9.54171 3.49645 9.14935 3.82284 8.82296C4.14922 8.49657 4.54159 8.33337 4.99992 8.33337C5.45825 8.33337 5.85061 8.49657 6.177 8.82296C6.50339 9.14935 6.66659 9.54171 6.66659 10C6.66659 10.4584 6.50339 10.8507 6.177 11.1771C5.85061 11.5035 5.45825 11.6667 4.99992 11.6667ZM9.99992 11.6667C9.54158 11.6667 9.14922 11.5035 8.82284 11.1771C8.49645 10.8507 8.33325 10.4584 8.33325 10C8.33325 9.54171 8.49645 9.14935 8.82284 8.82296C9.14922 8.49657 9.54158 8.33337 9.99992 8.33337C10.4583 8.33337 10.8506 8.49657 11.177 8.82296C11.5034 9.14935 11.6666 9.54171 11.6666 10C11.6666 10.4584 11.5034 10.8507 11.177 11.1771C10.8506 11.5035 10.4583 11.6667 9.99992 11.6667ZM14.9999 11.6667C14.5416 11.6667 14.1492 11.5035 13.8228 11.1771C13.4964 10.8507 13.3333 10.4584 13.3333 10C13.3333 9.54171 13.4964 9.14935 13.8228 8.82296C14.1492 8.49657 14.5416 8.33337 14.9999 8.33337C15.4583 8.33337 15.8506 8.49657 16.177 8.82296C16.5034 9.14935 16.6666 9.54171 16.6666 10C16.6666 10.4584 16.5034 10.8507 16.177 11.1771C15.8506 11.5035 15.4583 11.6667 14.9999 11.6667ZM4.99992 6.66671C4.54159 6.66671 4.14922 6.50351 3.82284 6.17712C3.49645 5.85074 3.33325 5.45837 3.33325 5.00004C3.33325 4.54171 3.49645 4.14935 3.82284 3.82296C4.14922 3.49657 4.54159 3.33337 4.99992 3.33337C5.45825 3.33337 5.85061 3.49657 6.177 3.82296C6.50339 4.14935 6.66659 4.54171 6.66659 5.00004C6.66659 5.45837 6.50339 5.85074 6.177 6.17712C5.85061 6.50351 5.45825 6.66671 4.99992 6.66671ZM9.99992 6.66671C9.54158 6.66671 9.14922 6.50351 8.82284 6.17712C8.49645 5.85074 8.33325 5.45837 8.33325 5.00004C8.33325 4.54171 8.49645 4.14935 8.82284 3.82296C9.14922 3.49657 9.54158 3.33337 9.99992 3.33337C10.4583 3.33337 10.8506 3.49657 11.177 3.82296C11.5034 4.14935 11.6666 4.54171 11.6666 5.00004C11.6666 5.45837 11.5034 5.85074 11.177 6.17712C10.8506 6.50351 10.4583 6.66671 9.99992 6.66671ZM14.9999 6.66671C14.5416 6.66671 14.1492 6.50351 13.8228 6.17712C13.4964 5.85074 13.3333 5.45837 13.3333 5.00004C13.3333 4.54171 13.4964 4.14935 13.8228 3.82296C14.1492 3.49657 14.5416 3.33337 14.9999 3.33337C15.4583 3.33337 15.8506 3.49657 16.177 3.82296C16.5034 4.14935 16.6666 4.54171 16.6666 5.00004C16.6666 5.45837 16.5034 5.85074 16.177 6.17712C15.8506 6.50351 15.4583 6.66671 14.9999 6.66671Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
||||
/// <reference types="@arcmantle/vite-plugin-import-css-sheet/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, esmExternalRequirePlugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
import externalGlobals from "rollup-plugin-external-globals";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import { importCSSSheet } from "@arcmantle/vite-plugin-import-css-sheet";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.tsx"),
|
||||
name: "element-web-module-banner",
|
||||
fileName: "index",
|
||||
formats: ["es"],
|
||||
},
|
||||
outDir: "lib",
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
rolldownOptions: {
|
||||
plugins: [
|
||||
esmExternalRequirePlugin({
|
||||
external: ["react"],
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
globals: {
|
||||
// Reuse React from the host app
|
||||
react: "window.React",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
importCSSSheet(),
|
||||
react(),
|
||||
svgr(),
|
||||
nodePolyfills({
|
||||
include: ["events"],
|
||||
}),
|
||||
externalGlobals({
|
||||
// Reuse React from the host app
|
||||
react: "window.React",
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
// Use production mode for the build as it is tested against production builds of Element Web,
|
||||
// this is required for React JSX versions to be compatible.
|
||||
"process.env.NODE_ENV": "'production'",
|
||||
"process": { env: { NODE_ENV: "production" } },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { defineConfig, devices, type Project } from "@playwright/test";
|
||||
import fs, { globSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Options } from "./playwright/element-web-test.ts";
|
||||
|
||||
const chromeProject = {
|
||||
...devices["Desktop Chrome"],
|
||||
channel: "chromium",
|
||||
permissions: ["clipboard-write", "clipboard-read", "microphone"],
|
||||
launchOptions: {
|
||||
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream", "--mute-audio"],
|
||||
},
|
||||
connectOptions: process.env.PW_TEST_CONNECT_WS_ENDPOINT
|
||||
? {
|
||||
wsEndpoint: process.env.PW_TEST_CONNECT_WS_ENDPOINT,
|
||||
exposeNetwork: "<loopback>",
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
We assume that all modules will have the following directory structure:
|
||||
<repo_root>
|
||||
└── modules/
|
||||
└── my-module/
|
||||
├── e2e/
|
||||
│ ├── test-1.spec.ts
|
||||
│ └── test-2.spec.ts
|
||||
└── package.json
|
||||
|
||||
The following code maps each such module (my-module in the example above) to a separate
|
||||
playwright project.
|
||||
*/
|
||||
const projects: Project<Options>[] = [];
|
||||
|
||||
// Get all the directories that hold playwright tests
|
||||
const moduleTestDirectories = globSync("modules/*/e2e", {});
|
||||
|
||||
// Process each directory
|
||||
for (const testDirectory of moduleTestDirectories) {
|
||||
// Based on the directory structure, the parent directory of the test directory holds package.json.
|
||||
const moduleDirectory = path.join(testDirectory, "..");
|
||||
|
||||
// Get module name from package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(moduleDirectory, "package.json"), "utf-8"));
|
||||
const MODULE_PREFIX = "@element-hq/element-web-module-";
|
||||
const name = packageJson.name.startsWith(MODULE_PREFIX)
|
||||
? packageJson.name.slice(MODULE_PREFIX.length)
|
||||
: packageJson.name;
|
||||
|
||||
// Create playwright project
|
||||
projects.push({
|
||||
name,
|
||||
use: {
|
||||
...chromeProject,
|
||||
moduleDir: moduleDirectory,
|
||||
},
|
||||
testDir: testDirectory,
|
||||
snapshotDir: `${testDirectory}/snapshots`,
|
||||
outputDir: `${testDirectory}/_results`,
|
||||
});
|
||||
}
|
||||
|
||||
const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080";
|
||||
|
||||
export default defineConfig<Options>({
|
||||
projects,
|
||||
use: {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
video: "retain-on-failure",
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: process.env.WEBAPP_PATH
|
||||
? `npx serve -p 8080 -L ${process.env.WEBAPP_PATH}`
|
||||
: "docker run --rm -p 8080:80 ghcr.io/element-hq/element-web:develop",
|
||||
url: `${baseURL}/config.json`,
|
||||
reuseExistingServer: true,
|
||||
timeout: (process.env.CI ? 30 : 120) * 1000,
|
||||
},
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
["list"],
|
||||
["html"],
|
||||
["github"],
|
||||
["@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js"],
|
||||
]
|
||||
: [["list"], ["html", { outputFolder: "playwright-html-report" }]],
|
||||
// When running the browser in docker, set the platform to `linux` as that is the platform where the browser is running
|
||||
snapshotPathTemplate: `{snapshotDir}/{testFilePath}/{arg}-${process.env.PW_TEST_CONNECT_WS_ENDPOINT ? "linux" : "{platform}"}{ext}`,
|
||||
forbidOnly: !!process.env.CI,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test as base, expect, type TestFixtures } from "@element-hq/element-web-playwright-common";
|
||||
|
||||
export interface Options {
|
||||
moduleDir: string;
|
||||
modules: string[];
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures & Options>({
|
||||
moduleDir: ["", { option: true }],
|
||||
modules: async ({ moduleDir }, use) => {
|
||||
await use([`${moduleDir}/lib/index.js`]);
|
||||
},
|
||||
|
||||
page: async ({ page, modules, config }, use) => {
|
||||
config.modules = [];
|
||||
for (let i = 0; i < modules.length; i++) {
|
||||
const module = `/modules/module-${i}/index.js`;
|
||||
await page.route(module, async (route) => {
|
||||
await route.fulfill({ path: modules[i] });
|
||||
});
|
||||
config.modules.push(module);
|
||||
}
|
||||
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,22 @@
|
||||
# @element-hq/element-web-module-restricted-guests
|
||||
|
||||
Restricted Guests module for Element Web.
|
||||
|
||||
Supports the following configuration options under the configuration key `io.element.element-web-modules.restricted-guests`:
|
||||
|
||||
| Key | Type | Description |
|
||||
| ------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| guest_user_homeserver_url | string | URL of the homeserver on which to register the guest, must be running the synapse module. |
|
||||
| guest_user_prefix | string | Prefix to apply to all guests registered via the module, defaults to `@guest-`. |
|
||||
| skip_single_sign_on | boolean | If true, the user will be forwarded to the login page instead of to the SSO login. This is only required if the home server has no SSO support. |
|
||||
|
||||
## Copyright & License
|
||||
|
||||
Copyright (c) 2025 New Vector Ltd
|
||||
|
||||
This software is multi licensed by New Vector Ltd (Element). It can be used either:
|
||||
|
||||
(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR
|
||||
|
||||
(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to).
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses.
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type MasConfig,
|
||||
type StartedMatrixAuthenticationServiceContainer,
|
||||
type StartedSynapseContainer,
|
||||
type SynapseContainer,
|
||||
} from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
|
||||
import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api";
|
||||
import { makePostgres, makeMas } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
|
||||
|
||||
import { RestrictedGuestsSynapseContainer, RestrictedGuestsSynapseWithMasContainer } from "./services";
|
||||
import { test as subBase, expect } from "../../playwright/element-web-test";
|
||||
|
||||
const MAS_CLIENT_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
|
||||
const MAS_CLIENT_SECRET = "restricted-guests-secret";
|
||||
const MAS_SHARED_SECRET = "restricted-guests-shared-secret";
|
||||
const MAS_INTERNAL_URL = "http://guest-mas:8080";
|
||||
const GUEST_HOMESERVER_NAME = "guest-homeserver";
|
||||
|
||||
const MAS_HTTP_LISTENERS: NonNullable<MasConfig["http"]>["listeners"] = [
|
||||
{
|
||||
name: "web",
|
||||
resources: [
|
||||
{ name: "discovery" },
|
||||
{ name: "human" },
|
||||
{ name: "oauth" },
|
||||
{ name: "compat" },
|
||||
{ name: "graphql" },
|
||||
{ name: "assets" },
|
||||
{ name: "adminapi" },
|
||||
],
|
||||
binds: [
|
||||
{
|
||||
address: "[::]:8080",
|
||||
},
|
||||
],
|
||||
proxy_protocol: false,
|
||||
},
|
||||
{
|
||||
name: "internal",
|
||||
resources: [
|
||||
{
|
||||
name: "health",
|
||||
},
|
||||
],
|
||||
binds: [
|
||||
{
|
||||
address: "[::]:8081",
|
||||
},
|
||||
],
|
||||
proxy_protocol: false,
|
||||
},
|
||||
];
|
||||
|
||||
const BASE_MAS_CONFIG: Partial<MasConfig> = {
|
||||
http: {
|
||||
listeners: MAS_HTTP_LISTENERS,
|
||||
public_base: "",
|
||||
},
|
||||
policy: {
|
||||
data: {
|
||||
admin_clients: [MAS_CLIENT_ID],
|
||||
client_registration: {
|
||||
allow_insecure_uris: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
clients: [
|
||||
{
|
||||
client_id: MAS_CLIENT_ID,
|
||||
client_auth_method: "client_secret_basic",
|
||||
client_secret: MAS_CLIENT_SECRET,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
declare module "@element-hq/element-web-module-api" {
|
||||
export interface Config {
|
||||
embedded_pages?: {
|
||||
login_for_welcome?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// We do some wacky things here in order to run the test suite against multiple homeserver configurations
|
||||
const base = subBase.extend<
|
||||
{
|
||||
testRoomId: string;
|
||||
},
|
||||
{
|
||||
auth: "mas" | "legacy";
|
||||
|
||||
bot: Credentials;
|
||||
guestMas?: StartedMatrixAuthenticationServiceContainer;
|
||||
guestHomeserver: StartedSynapseContainer;
|
||||
}
|
||||
>({
|
||||
testRoomId: [
|
||||
async ({ homeserver, bot }, use) => {
|
||||
const { room_id: roomId } = (await homeserver.csApi.request("POST", "/v3/createRoom", bot.accessToken, {
|
||||
name: "Test room",
|
||||
preset: "public_chat",
|
||||
topic: "All about happy hour",
|
||||
initial_state: [
|
||||
{
|
||||
// This is required to allow guests to join the room with this Synapse module
|
||||
type: "m.room.join_rule",
|
||||
state_key: "",
|
||||
content: { join_rule: "knock" },
|
||||
},
|
||||
],
|
||||
})) as { room_id: string };
|
||||
await use(roomId);
|
||||
},
|
||||
{ scope: "test" },
|
||||
],
|
||||
bot: [
|
||||
async ({ homeserver }, use) => {
|
||||
const bot = await homeserver.registerUser("bot", "pAs5w0rD!", "Bot");
|
||||
await use(bot);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
auth: ["mas", { scope: "worker" }],
|
||||
// Optional MAS on the default homeserver, enabled only when we are testing the non-guest login UX
|
||||
mas: [
|
||||
async ({ logger, network, postgres, auth, synapseConfig }, use) => {
|
||||
if (auth !== "mas" || synapseConfig.allow_guest_access !== false) {
|
||||
return use(undefined);
|
||||
}
|
||||
const container = await makeMas(
|
||||
postgres,
|
||||
network,
|
||||
logger,
|
||||
{
|
||||
...BASE_MAS_CONFIG,
|
||||
matrix: {
|
||||
kind: "synapse",
|
||||
homeserver: "homeserver",
|
||||
endpoint: "http://homeserver:8008",
|
||||
secret: MAS_SHARED_SECRET,
|
||||
},
|
||||
},
|
||||
"mas",
|
||||
);
|
||||
await use(container);
|
||||
await container.stop();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
// Optional MAS on the module homeserver
|
||||
guestMas: [
|
||||
async ({ logger, network, auth }, use) => {
|
||||
if (auth !== "mas") {
|
||||
return use(undefined);
|
||||
}
|
||||
|
||||
// We need a separate postgres so it doesn't fight with the default MAS
|
||||
const postgres = await makePostgres(network, logger, "guest-mas-postgres");
|
||||
|
||||
const container = await makeMas(
|
||||
postgres,
|
||||
network,
|
||||
logger,
|
||||
{
|
||||
...BASE_MAS_CONFIG,
|
||||
matrix: {
|
||||
kind: "synapse",
|
||||
homeserver: GUEST_HOMESERVER_NAME,
|
||||
endpoint: "http://guest-homeserver:8008",
|
||||
secret: MAS_SHARED_SECRET,
|
||||
},
|
||||
},
|
||||
"guest-mas",
|
||||
);
|
||||
await use(container);
|
||||
await container.stop();
|
||||
await postgres.stop();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
// Module homeserver
|
||||
guestHomeserver: [
|
||||
async ({ logger, synapseConfig, network, guestMas }, use) => {
|
||||
let container: SynapseContainer;
|
||||
if (guestMas) {
|
||||
container = new RestrictedGuestsSynapseWithMasContainer({
|
||||
adminApiBaseUrl: MAS_INTERNAL_URL,
|
||||
oauthBaseUrl: MAS_INTERNAL_URL,
|
||||
clientId: MAS_CLIENT_ID,
|
||||
clientSecret: MAS_CLIENT_SECRET,
|
||||
}).withMatrixAuthenticationService(guestMas);
|
||||
} else {
|
||||
container = new RestrictedGuestsSynapseContainer();
|
||||
}
|
||||
|
||||
const startedContainer = await container
|
||||
.withConfig(synapseConfig)
|
||||
.withConfig({
|
||||
server_name: GUEST_HOMESERVER_NAME,
|
||||
})
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(GUEST_HOMESERVER_NAME)
|
||||
.withLogConsumer(logger.getConsumer("guest_homeserver"))
|
||||
.start();
|
||||
|
||||
await use(startedContainer);
|
||||
await startedContainer.stop();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
displayName: "Tommy",
|
||||
labsFlags: ["feature_ask_to_join"],
|
||||
config: {
|
||||
embedded_pages: {
|
||||
login_for_welcome: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
base.slow();
|
||||
for (const auth of ["mas", "legacy"] as const) {
|
||||
for (const guestsEnabled of [true, false]) {
|
||||
const test = base.extend({
|
||||
auth,
|
||||
synapseConfig: {
|
||||
allow_guest_access: guestsEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
test.describe(`Restricted guests auth=${auth} guests=${guestsEnabled}`, () => {
|
||||
test("should error if config is missing", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
|
||||
await expect(page.getByText("Errors in module configuration")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("with config", () => {
|
||||
test.beforeEach(async ({ config, guestHomeserver, page, testRoomId }) => {
|
||||
config["io.element.element-web-modules.restricted-guests"] = {
|
||||
guest_user_homeserver_url: guestHomeserver.baseUrl,
|
||||
};
|
||||
// Go to a room we are not a member of
|
||||
await page.goto(`/#/room/${testRoomId}`);
|
||||
});
|
||||
|
||||
if (guestsEnabled) {
|
||||
// The screenshots between the two auth type tests for guests should be identical.
|
||||
test(
|
||||
"should show the default room preview bar for logged in users",
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, testRoomId }) => {
|
||||
// Go to a room we are not a member of
|
||||
await page.goto(`/#/room/${testRoomId}`);
|
||||
const button = page.getByRole("button", { name: "Join the discussion" });
|
||||
await expect(button).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should show the module's room preview bar for guests",
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page }) => {
|
||||
const button = page.getByRole("button", { name: "Join as guest", exact: true });
|
||||
await expect(button).toBeVisible();
|
||||
await expect(page.locator(".mx_RoomPreviewBar")).toMatchScreenshot(`preview-bar.png`);
|
||||
|
||||
await button.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toMatchScreenshot(`dialog.png`);
|
||||
|
||||
await dialog.getByPlaceholder("Name").fill("Jim");
|
||||
await dialog.getByRole("button", { name: "Continue as guest" }).click();
|
||||
|
||||
await expect(page.getByText("Ask to join?")).toBeVisible();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
test("should show the module login ux", { tag: ["@screenshot"] }, async ({ page }) => {
|
||||
const button = page.getByRole("button", { name: "Join as guest", exact: true });
|
||||
await expect(button).toBeVisible();
|
||||
await expect(page.getByRole("main")).toMatchScreenshot(`login-${auth}.png`);
|
||||
|
||||
await button.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toMatchScreenshot(`dialog-login.png`);
|
||||
|
||||
await dialog.getByPlaceholder("Name").fill("Jim");
|
||||
await dialog.getByRole("button", { name: "Continue as guest" }).click();
|
||||
|
||||
await expect(page.getByText("Join the discussion")).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
StartedSynapseContainer,
|
||||
SynapseContainer,
|
||||
} from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
|
||||
import path, { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// We use the SynapseContainer as a base to have all of its utilities for config setting
|
||||
export class RestrictedGuestsSynapseContainer extends SynapseContainer {
|
||||
protected getModuleConfig(): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public override async start(): Promise<StartedSynapseContainer> {
|
||||
this.withCopyDirectoriesToContainer([
|
||||
{
|
||||
source: path.resolve(__dirname, "..", "..", "synapse", "synapse_guest_module"),
|
||||
target: "/modules/synapse_guest_module/",
|
||||
},
|
||||
]).withEnvironment({
|
||||
PYTHONPATH: "/modules",
|
||||
});
|
||||
this.config.modules.push({
|
||||
module: "synapse_guest_module.GuestModule",
|
||||
config: this.getModuleConfig(),
|
||||
});
|
||||
|
||||
return super.start();
|
||||
}
|
||||
}
|
||||
|
||||
interface RestrictedGuestsMasModuleConfig {
|
||||
adminApiBaseUrl: string;
|
||||
oauthBaseUrl?: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class RestrictedGuestsSynapseWithMasContainer extends RestrictedGuestsSynapseContainer {
|
||||
public constructor(private readonly masConfig: RestrictedGuestsMasModuleConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected override getModuleConfig(): Record<string, unknown> {
|
||||
return {
|
||||
mas: {
|
||||
admin_api_base_url: this.masConfig.adminApiBaseUrl,
|
||||
oauth_base_url: this.masConfig.oauthBaseUrl ?? this.masConfig.adminApiBaseUrl,
|
||||
client_id: this.masConfig.clientId,
|
||||
client_secret: this.masConfig.clientSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@element-hq/element-web-module-restricted-guests",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "pnpm lint:types && pnpm lint:js",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:js": "eslint --max-warnings 0 src -c ../.eslintrc.cjs",
|
||||
"test:playwright": "playwright test -c ../playwright.config.ts",
|
||||
"test:playwright:open": "yarn test:playwright -c ../playwright.config.ts --ui",
|
||||
"test:playwright:screenshots": "playwright-screenshots yarn test:playwright --update-snapshots --grep @screenshot"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
|
||||
"@element-hq/element-web-module-api": "workspace:*",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"rollup-plugin-external-globals": "^0.13.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-node-polyfills": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-web": "^9.0.0",
|
||||
"styled-components": "^6.1.18",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { type AccountAuthInfo, type Api } from "@element-hq/element-web-module-api";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { type ModuleConfig } from "./config.ts";
|
||||
import RegisterDialog from "./RegisterDialog.tsx";
|
||||
|
||||
interface Props {
|
||||
api: Api;
|
||||
config: ModuleConfig;
|
||||
onLoggedIn(data: AccountAuthInfo): void;
|
||||
}
|
||||
|
||||
const Container = styled.aside`
|
||||
margin: var(--cpd-space-3x) 0;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const AuthFooter: FC<Props> = ({ api, config, onLoggedIn }) => {
|
||||
const onTryJoin = async (): Promise<void> => {
|
||||
const { finished } = api.openDialog(
|
||||
{
|
||||
title: api.i18n.translate("register_dialog_title"),
|
||||
},
|
||||
RegisterDialog,
|
||||
{
|
||||
api,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
const { model: accountAuthInfo, ok } = await finished;
|
||||
|
||||
if (ok && accountAuthInfo) {
|
||||
onLoggedIn(accountAuthInfo);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button onClick={onTryJoin} size="md" kind="secondary">
|
||||
{api.i18n.translate("join_cta")}
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthFooter;
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useState, type JSX, type FormEvent } from "react";
|
||||
import { type Api, type AccountAuthInfo, type DialogProps } from "@element-hq/element-web-module-api";
|
||||
import { Form } from "@vector-im/compound-web";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { type ModuleConfig } from "./config.ts";
|
||||
|
||||
interface RegisterDialogProps extends DialogProps<AccountAuthInfo> {
|
||||
api: Api;
|
||||
config: ModuleConfig;
|
||||
showLoginLink?: boolean;
|
||||
}
|
||||
|
||||
const enum State {
|
||||
Idle,
|
||||
Busy,
|
||||
Error,
|
||||
}
|
||||
|
||||
const StyledFormRoot = styled(Form.Root)`
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
||||
font-feature-settings: normal;
|
||||
`;
|
||||
|
||||
const RegisterDialog: FC<RegisterDialogProps> = ({ api, config, onCancel, onSubmit, showLoginLink }) => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [state, setState] = useState<State>(State.Idle);
|
||||
|
||||
async function trySubmit(ev: FormEvent): Promise<void> {
|
||||
ev.preventDefault();
|
||||
setState(State.Busy);
|
||||
|
||||
try {
|
||||
const homeserverUrl = config.guest_user_homeserver_url;
|
||||
|
||||
const url = new URL("/_synapse/client/register_guest", homeserverUrl);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayname: username }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const accountAuthInfo = await response.json();
|
||||
onSubmit(accountAuthInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to create guest account", e);
|
||||
setState(State.Error);
|
||||
}
|
||||
}
|
||||
|
||||
let message: JSX.Element | undefined;
|
||||
if (state === State.Error) {
|
||||
message = <Form.ErrorMessage>{api.i18n.translate("register_dialog_error")}</Form.ErrorMessage>;
|
||||
} else if (state === State.Busy) {
|
||||
message = <Form.LoadingMessage>{api.i18n.translate("register_dialog_busy")}</Form.LoadingMessage>;
|
||||
}
|
||||
|
||||
const disabled = state !== State.Idle;
|
||||
|
||||
return (
|
||||
<StyledFormRoot onSubmit={trySubmit}>
|
||||
<Form.Field name="name">
|
||||
<Form.Label>{api.i18n.translate("register_dialog_register_username_label")}</Form.Label>
|
||||
<Form.TextControl
|
||||
disabled={disabled}
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
setUsername(event.currentTarget.value);
|
||||
}}
|
||||
placeholder={api.i18n.translate("register_dialog_field_label")}
|
||||
/>
|
||||
{message}
|
||||
</Form.Field>
|
||||
|
||||
{showLoginLink && (
|
||||
<a href={config.skip_single_sign_on ? "/#/login" : "/#/start_sso"} onClick={onCancel}>
|
||||
{api.i18n.translate("register_dialog_existing_account")}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<Form.Submit disabled={disabled || !username}>
|
||||
{api.i18n.translate("register_dialog_continue_label")}
|
||||
</Form.Submit>
|
||||
</StyledFormRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterDialog;
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, type JSX } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { type Api } from "@element-hq/element-web-module-api";
|
||||
import styled from "styled-components";
|
||||
import { useWatchable } from "@element-hq/element-web-module-api";
|
||||
|
||||
import { type ModuleConfig } from "./config.ts";
|
||||
import RegisterDialog from "./RegisterDialog.tsx";
|
||||
|
||||
interface RoomPreviewBarProps {
|
||||
api: Api;
|
||||
config: ModuleConfig;
|
||||
children: JSX.Element;
|
||||
roomId?: string;
|
||||
roomAlias?: string;
|
||||
promptAskToJoin?: boolean;
|
||||
}
|
||||
|
||||
const Container = styled.aside`
|
||||
margin: auto;
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
||||
font-feature-settings: normal;
|
||||
`;
|
||||
|
||||
const RoomPreviewBar: FC<RoomPreviewBarProps> = ({ api, config, roomId, roomAlias, promptAskToJoin, children }) => {
|
||||
const profile = useWatchable(api.profile);
|
||||
const isGuest = profile.isGuest;
|
||||
|
||||
if (promptAskToJoin || !isGuest || !(roomId || roomAlias)) return children;
|
||||
|
||||
const onTryJoin = async (): Promise<void> => {
|
||||
const { finished } = api.openDialog(
|
||||
{
|
||||
title: api.i18n.translate("register_dialog_title"),
|
||||
},
|
||||
RegisterDialog,
|
||||
{
|
||||
api,
|
||||
config,
|
||||
showLoginLink: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { model: accountAuthInfo, ok } = await finished;
|
||||
|
||||
if (ok && accountAuthInfo) {
|
||||
await api.overwriteAccountAuth(accountAuthInfo);
|
||||
await api.navigation.toMatrixToLink(`https://matrix.to/#/${roomId ?? roomAlias}`, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="mx_RoomPreviewBar">
|
||||
<div className="mx_RoomPreviewBar_message">{api.i18n.translate("join_message")}</div>
|
||||
<div className="mx_RoomPreviewBar_actions">
|
||||
<Button onClick={onTryJoin}>{api.i18n.translate("join_cta")}</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomPreviewBar;
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { z, type ZodMiniType, type input } from "zod/mini";
|
||||
|
||||
z.config(z.locales.en());
|
||||
|
||||
export const ModuleConfig = z.object({
|
||||
/**
|
||||
* The URL of the homeserver where the guest users should be registered. This
|
||||
* must have the `synapse-restricted-guests-module` installed.
|
||||
* @example `https://synapse.local`
|
||||
*/
|
||||
guest_user_homeserver_url: z.url(),
|
||||
|
||||
/**
|
||||
* The username prefix that identifies guest users.
|
||||
* @defaultValue `@guest-`
|
||||
*/
|
||||
guest_user_prefix: z._default(z.string().check(z.regex(/@[a-zA-Z-_1-9]+/)), "@guest-"),
|
||||
|
||||
/**
|
||||
* If true, the user will be forwarded to the login page instead of to the SSO
|
||||
* login. This is only required if the home server has no SSO support.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
skip_single_sign_on: z._default(z.boolean(), false),
|
||||
});
|
||||
|
||||
export type ModuleConfig = z.infer<typeof ModuleConfig>;
|
||||
|
||||
type ConfigSchema = ZodMiniType<z.output<typeof ModuleConfig>, z.input<typeof ModuleConfig>>;
|
||||
|
||||
export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests";
|
||||
|
||||
declare module "@element-hq/element-web-module-api" {
|
||||
export interface Config {
|
||||
[CONFIG_KEY]: input<ConfigSchema>;
|
||||
sso_redirect_options?: {
|
||||
immediate?: boolean; // incompatible option
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import compound from "@vector-im/compound-web/dist/style.css" with { type: "css" };
|
||||
|
||||
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
|
||||
import Translations from "./translations.json";
|
||||
import { ModuleConfig, CONFIG_KEY } from "./config";
|
||||
import { name as ModuleName } from "../package.json";
|
||||
import RoomPreviewBar from "./RoomPreviewBar.tsx";
|
||||
import AuthFooter from "./AuthFooter.tsx";
|
||||
|
||||
const GUEST_INVISIBLE_COMPONENTS = [
|
||||
"UIComponent.sendInvites",
|
||||
"UIComponent.roomCreation",
|
||||
"UIComponent.spaceCreation",
|
||||
"UIComponent.exploreRooms",
|
||||
"UIComponent.roomOptionsMenu",
|
||||
"UIComponent.addIntegrations",
|
||||
];
|
||||
|
||||
class RestrictedGuestsModule implements Module {
|
||||
public static readonly moduleApiVersion = "^1.0.0";
|
||||
|
||||
private config?: ModuleConfig;
|
||||
|
||||
public constructor(private api: Api) {}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
document.adoptedStyleSheets.push(compound);
|
||||
|
||||
this.api.i18n.register(Translations);
|
||||
|
||||
try {
|
||||
this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY));
|
||||
} catch (e) {
|
||||
console.error("Failed to init module", e);
|
||||
throw new Error(`Errors in module configuration for "${ModuleName}"`);
|
||||
}
|
||||
|
||||
const appConfig = this.api.config.get();
|
||||
if (appConfig.sso_redirect_options?.immediate) {
|
||||
console.warn(`${ModuleName} found incompatible option 'sso_redirect_options.immediate', turning it off.`);
|
||||
appConfig.sso_redirect_options.immediate = false;
|
||||
}
|
||||
|
||||
// Room preview bar customisations (for Matrix guest support)
|
||||
this.api.customComponents.registerRoomPreviewBar((props, OriginalComponent) => (
|
||||
<RoomPreviewBar {...props} api={this.api} config={this.config!}>
|
||||
<OriginalComponent {...props} />
|
||||
</RoomPreviewBar>
|
||||
));
|
||||
this.api.customisations.registerShouldShowComponent(this.shouldShowComponent);
|
||||
|
||||
// Login component customisations (for no guest support)
|
||||
this.api.customComponents.registerLoginComponent((props, OriginalComponent) => (
|
||||
<OriginalComponent {...props}>
|
||||
<AuthFooter onLoggedIn={props.onLoggedIn} api={this.api} config={this.config!} />
|
||||
</OriginalComponent>
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true, if the `userId` should see the `component`.
|
||||
*
|
||||
* @param component - the name of the component that is checked
|
||||
* @returns true, if the user should see the component
|
||||
*/
|
||||
public readonly shouldShowComponent = (component: string): boolean => {
|
||||
const profile = this.api.profile.value;
|
||||
if (this.config && (profile.isGuest || profile.userId?.startsWith(this.config.guest_user_prefix))) {
|
||||
return GUEST_INVISIBLE_COMPONENTS.includes(component);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export default RestrictedGuestsModule satisfies ModuleFactory;
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"register_dialog_register_username_label": {
|
||||
"en": "Username",
|
||||
"de": "Benutzername"
|
||||
},
|
||||
"register_dialog_title": {
|
||||
"en": "Request access",
|
||||
"de": "Zugriff anfordern"
|
||||
},
|
||||
"register_dialog_busy": {
|
||||
"en": "Creating your account...",
|
||||
"de": "Erstelle dein Konto..."
|
||||
},
|
||||
"register_dialog_continue_label": {
|
||||
"en": "Continue as guest",
|
||||
"de": "Als Gast fortfahren"
|
||||
},
|
||||
"register_dialog_field_label": {
|
||||
"en": "Name",
|
||||
"de": "Name"
|
||||
},
|
||||
"register_dialog_existing_account": {
|
||||
"en": "I already have an account.",
|
||||
"de": "Ich habe bereits einen Account."
|
||||
},
|
||||
"register_dialog_error": {
|
||||
"en": "The account creation failed.",
|
||||
"de": "Die Anmeldung als Gastnutzer ist fehlgeschlagen."
|
||||
},
|
||||
"join_message": {
|
||||
"en": "Join the room to participate",
|
||||
"de": "Treten Sie dem Raum bei, um teilzunehmen"
|
||||
},
|
||||
"join_cta": {
|
||||
"en": "Join as guest",
|
||||
"de": "Als Gast beitreten"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/// <reference types="@arcmantle/vite-plugin-import-css-sheet/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, esmExternalRequirePlugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
import externalGlobals from "rollup-plugin-external-globals";
|
||||
import { importCSSSheet } from "@arcmantle/vite-plugin-import-css-sheet";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.tsx"),
|
||||
name: "element-web-module-restricted-guests",
|
||||
fileName: "index",
|
||||
formats: ["es"],
|
||||
},
|
||||
outDir: "lib",
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
rolldownOptions: {
|
||||
plugins: [
|
||||
esmExternalRequirePlugin({
|
||||
external: ["react"],
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
globals: {
|
||||
// Reuse React from the host app
|
||||
react: "window.React",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
importCSSSheet(),
|
||||
react(),
|
||||
nodePolyfills({
|
||||
include: ["events"],
|
||||
}),
|
||||
externalGlobals({
|
||||
// Reuse React from the host app
|
||||
react: "window.React",
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
// Use production mode for the build as it is tested against production builds of Element Web,
|
||||
// this is required for React JSX versions to be compatible.
|
||||
"process.env.NODE_ENV": "'production'",
|
||||
"process": { env: { NODE_ENV: "production" } },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "es2024",
|
||||
"jsx": "react",
|
||||
"lib": ["ESNext", "es2024", "dom", "dom.iterable"],
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["playwright.config.ts", "playwright"]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# @element-hq/element-web-module-widget-lifecycle
|
||||
|
||||
Widget lifecycle module for Element Web.
|
||||
|
||||
Supports the following configuration options under the configuration key `io.element.element-web-modules.widget-lifecycle`:
|
||||
|
||||
| Key | Type | Description |
|
||||
| ------------------ | ------ | ------------------------------------------------ |
|
||||
| widget_permissions | object | Map of widget URL patterns to approval settings. |
|
||||
|
||||
Each widget configuration can use the following options:
|
||||
|
||||
- `preload_approved` - if true, the preload dialog is not displayed for this widget.
|
||||
- `identity_approved` - if true, requests for an identity token are automatically accepted.
|
||||
- `capabilities_approved` - a list of capabilities that should be approved for this widget.
|
||||
|
||||
The widget URL and capability strings can use a trailing `*` to match multiple widgets or capabilities.
|
||||
This is useful when widgets have multiple routes or capabilities include variable state keys.
|
||||
|
||||
## Matching and precedence
|
||||
|
||||
- Patterns ending in `*` are matched by prefix; other patterns must match exactly.
|
||||
- If multiple rules match, the most specific match wins per field.
|
||||
- The capabilities allow-list is not merged across rules; the most specific rule that defines it wins.
|
||||
|
||||
## Example configuration (exact match)
|
||||
|
||||
```json
|
||||
{
|
||||
"io.element.element-web-modules.widget-lifecycle": {
|
||||
"widget_permissions": {
|
||||
"https://widget.example.com/": {
|
||||
"preload_approved": true,
|
||||
"identity_approved": true,
|
||||
"capabilities_approved": [
|
||||
"org.matrix.msc2931.navigate",
|
||||
"org.matrix.msc2762.receive.state_event:m.room.power_levels"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example configuration (wildcards)
|
||||
|
||||
```json
|
||||
{
|
||||
"io.element.element-web-modules.widget-lifecycle": {
|
||||
"widget_permissions": {
|
||||
"https://widget.example.com/*": {
|
||||
"preload_approved": true,
|
||||
"identity_approved": true,
|
||||
"capabilities_approved": [
|
||||
"org.matrix.msc2931.navigate",
|
||||
"org.matrix.msc2762.receive.state_event:m.room.power_levels",
|
||||
"org.matrix.msc2762.send.state_event:net.custom_event#*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
<!--
|
||||
Minimal fixture widget used by e2e tests to exercise the Matrix Widget API lifecycle:
|
||||
1. Element requests capabilities → widget declares what it needs
|
||||
2. Element notifies approval → widget requests an OpenID token
|
||||
3. Element grants the token → widget resolves the user's Matrix ID and displays it
|
||||
|
||||
The `caps` query parameter controls which capabilities the widget requests (comma-separated).
|
||||
Pass `caps=none` to request none. Defaults to m.room.topic if omitted.
|
||||
The `hsUrl` query parameter is required for the OpenID userinfo lookup in step 3.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Demo Widget</title>
|
||||
<script>
|
||||
const getCapabilities = () => {
|
||||
const caps = new URLSearchParams(window.location.search).get("caps");
|
||||
if (!caps) {
|
||||
return ["org.matrix.msc2762.receive.state_event:m.room.topic"];
|
||||
}
|
||||
if (caps === "none") return [];
|
||||
return caps
|
||||
.split(",")
|
||||
.map((cap) => cap.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
let sendEventCount = 0;
|
||||
window.onmessage = async (ev) => {
|
||||
if (ev.data.action === "capabilities") {
|
||||
// Step 1: Element is asking what capabilities this widget needs.
|
||||
window.parent.postMessage(
|
||||
Object.assign(
|
||||
{
|
||||
response: {
|
||||
capabilities: getCapabilities(),
|
||||
},
|
||||
},
|
||||
ev.data,
|
||||
),
|
||||
"*",
|
||||
);
|
||||
} else if (ev.data.action === "notify_capabilities") {
|
||||
// Step 2: Capabilities have been approved. Request an OpenID token
|
||||
// so we can identify the current user in step 3.
|
||||
window.parent.postMessage(
|
||||
{
|
||||
api: "fromWidget",
|
||||
widgetId: ev.data.widgetId,
|
||||
requestId: "widget-" + sendEventCount,
|
||||
action: "get_openid",
|
||||
data: {},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} else if (ev.data.action === "get_openid" && ev.data.response?.state === "allowed") {
|
||||
// Step 3: Token granted — exchange it for the user's Matrix ID and display it.
|
||||
// Tests assert on this heading to verify identity was passed through correctly.
|
||||
const { access_token } = ev.data.response;
|
||||
|
||||
const hsUrl = new URLSearchParams(window.location.search).get("hsUrl");
|
||||
const response = await fetch(
|
||||
`${hsUrl}/_matrix/federation/v1/openid/userinfo?access_token=${access_token}`,
|
||||
);
|
||||
const { sub } = await response.json();
|
||||
|
||||
const titleElement = document.getElementById("title");
|
||||
titleElement.innerText = `Hello ${sub}!`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">Hello unknown!</h1>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type SynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js";
|
||||
import { rejectToastIfExists } from "@element-hq/element-web-playwright-common";
|
||||
|
||||
import { test, expect } from "../../playwright/element-web-test.ts";
|
||||
|
||||
declare module "@element-hq/element-web-module-api" {
|
||||
interface Config {
|
||||
"io.element.element-web-modules.widget-lifecycle": {
|
||||
widget_permissions: {
|
||||
[url: string]: {
|
||||
preload_approved?: boolean;
|
||||
identity_approved?: boolean;
|
||||
capabilities_approved?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const WIDGET_URL = "http://localhost:8080/widget.html";
|
||||
|
||||
test.use({
|
||||
displayName: "Timmy",
|
||||
synapseConfig: async ({ synapseConfig, _homeserver: homeserver }, use) => {
|
||||
(homeserver as SynapseContainer).withConfigField("listeners[0].resources[0].names", ["client", "openid"]);
|
||||
await use(synapseConfig);
|
||||
},
|
||||
page: async ({ context, page, moduleDir }, use) => {
|
||||
await context.route(`${WIDGET_URL}*`, async (route) => {
|
||||
await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await use(page);
|
||||
},
|
||||
bypassCSP: true,
|
||||
launchOptions: {
|
||||
args: ["--disable-web-security"],
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Widget Lifecycle", () => {
|
||||
test.describe("trusted widgets", () => {
|
||||
// Configure the module to pre-approve the widget URL for preloading, identity tokens,
|
||||
// and the m.room.topic state event capability.
|
||||
test.use({
|
||||
config: {
|
||||
"io.element.element-web-modules.widget-lifecycle": {
|
||||
widget_permissions: {
|
||||
[`${WIDGET_URL}*`]: {
|
||||
preload_approved: true,
|
||||
identity_approved: true,
|
||||
capabilities_approved: ["org.matrix.msc2762.receive.state_event:m.room.topic"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test("auto-approves preload and identity", async ({ page, user, homeserver }, testInfo) => {
|
||||
rejectToastIfExists(page, "Verify this device");
|
||||
|
||||
// A bot creates a room with the widget pinned to the top panel, then invites the test user.
|
||||
// Because the widget was added by a different user (the bot), Element would normally show a
|
||||
// preload consent dialog before loading it — this test verifies that dialog is skipped.
|
||||
const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot");
|
||||
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
||||
"POST",
|
||||
"/v3/createRoom",
|
||||
bot.accessToken,
|
||||
{
|
||||
name: "Trusted Widget",
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request<{ event_id: string }>(
|
||||
"PUT",
|
||||
`/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`,
|
||||
bot.accessToken,
|
||||
{
|
||||
id: "1",
|
||||
creatorUserId: bot.userId,
|
||||
type: "custom",
|
||||
name: "Trusted Widget",
|
||||
url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic`,
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request(
|
||||
"PUT",
|
||||
`/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`,
|
||||
bot.accessToken,
|
||||
{
|
||||
widgets: {
|
||||
"1": {
|
||||
container: "top",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, {
|
||||
user_id: user.userId,
|
||||
});
|
||||
|
||||
await page.getByText("Trusted Widget").click();
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// No preload dialog should appear — the widget loads immediately.
|
||||
await expect(page.getByRole("button", { name: "Continue" })).not.toBeVisible();
|
||||
|
||||
// The widget greets the user by ID, proving the identity token was also auto-approved
|
||||
// and passed to the widget without any consent prompts.
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('iframe[title="Trusted Widget"]')
|
||||
.getByRole("heading", { name: `Hello ${user.userId}!` }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("prompts for capabilities not in the allowlist", async ({ page, user, homeserver }, testInfo) => {
|
||||
rejectToastIfExists(page, "Verify this device");
|
||||
|
||||
const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot");
|
||||
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
||||
"POST",
|
||||
"/v3/createRoom",
|
||||
bot.accessToken,
|
||||
{
|
||||
name: "Capabilities Widget",
|
||||
},
|
||||
);
|
||||
// The widget requests two capabilities: m.room.topic (in the allowlist) and m.room.name (not in the allowlist).
|
||||
await homeserver.csApi.request<{ event_id: string }>(
|
||||
"PUT",
|
||||
`/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`,
|
||||
bot.accessToken,
|
||||
{
|
||||
id: "1",
|
||||
creatorUserId: bot.userId,
|
||||
type: "custom",
|
||||
name: "Capabilities Widget",
|
||||
url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic,org.matrix.msc2762.receive.state_event:m.room.name`,
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request(
|
||||
"PUT",
|
||||
`/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`,
|
||||
bot.accessToken,
|
||||
{
|
||||
widgets: {
|
||||
"1": {
|
||||
container: "top",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, {
|
||||
user_id: user.userId,
|
||||
});
|
||||
|
||||
await page.getByText("Capabilities Widget").click();
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// A capabilities approval dialog should appear since m.room.name was not pre-approved.
|
||||
await expect(page.getByRole("button", { name: "Approve" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("untrusted widgets", () => {
|
||||
// No widget URLs are pre-approved, so all lifecycle prompts should be shown to the user.
|
||||
test.use({
|
||||
config: {
|
||||
"io.element.element-web-modules.widget-lifecycle": {
|
||||
widget_permissions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test("shows preload, capabilities, and OpenID dialogs for untrusted widgets", async ({
|
||||
page,
|
||||
user,
|
||||
homeserver,
|
||||
}, testInfo) => {
|
||||
rejectToastIfExists(page, "Verify this device");
|
||||
|
||||
const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot");
|
||||
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
||||
"POST",
|
||||
"/v3/createRoom",
|
||||
bot.accessToken,
|
||||
{
|
||||
name: "Untrusted Widget",
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request<{ event_id: string }>(
|
||||
"PUT",
|
||||
`/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`,
|
||||
bot.accessToken,
|
||||
{
|
||||
id: "1",
|
||||
creatorUserId: bot.userId,
|
||||
type: "custom",
|
||||
name: "Untrusted Widget",
|
||||
url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic`,
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request(
|
||||
"PUT",
|
||||
`/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`,
|
||||
bot.accessToken,
|
||||
{
|
||||
widgets: {
|
||||
"1": {
|
||||
container: "top",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, {
|
||||
user_id: user.userId,
|
||||
});
|
||||
|
||||
await page.getByText("Untrusted Widget").click();
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// 1. Preload consent dialog — shown because the widget was added by another user (the bot).
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 2. Capabilities dialog — the widget requests m.room.topic once it loads.
|
||||
await expect(page.getByRole("button", { name: "Approve" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Approve" }).click();
|
||||
|
||||
// 3. OpenID identity dialog — the widget requests an identity token after capabilities are granted.
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@element-hq/element-web-module-widget-lifecycle",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "pnpm lint:types && pnpm lint:js",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:js": "eslint --max-warnings 0 src -c ../.eslintrc.cjs",
|
||||
"test:playwright": "playwright test -c ../playwright.config.ts",
|
||||
"test:playwright:open": "yarn test:playwright -c ../playwright.config.ts --ui",
|
||||
"test:playwright:screenshots": "playwright-screenshots yarn test:playwright --update-snapshots --grep @screenshot"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-web-module-api": "workspace:*",
|
||||
"@types/node": "^22.10.7",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Api, Module, WidgetDescriptor, WidgetLifecycleApi } from "@element-hq/element-web-module-api";
|
||||
import { CONFIG_KEY, parseWidgetLifecycleConfig, type WidgetLifecycleModuleConfig } from "./config";
|
||||
import { constructWidgetPermissions } from "./utils/constructWidgetPermissions";
|
||||
import { matchPattern } from "./utils/matchPattern";
|
||||
|
||||
/** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */
|
||||
export type WidgetLifecycleApiAdapter = Pick<
|
||||
WidgetLifecycleApi,
|
||||
"registerPreloadApprover" | "registerIdentityApprover" | "registerCapabilitiesApprover"
|
||||
>;
|
||||
|
||||
type ModuleApi = Pick<Api, "config" | "widgetLifecycle">;
|
||||
|
||||
/**
|
||||
* Module that auto-approves widget preloading, identity token requests, and capability
|
||||
* requests based on URL-pattern rules defined in config.json.
|
||||
*/
|
||||
export default class WidgetLifecycleModule implements Module {
|
||||
public static readonly moduleApiVersion = "^1.10.0";
|
||||
|
||||
private config: WidgetLifecycleModuleConfig = {};
|
||||
|
||||
public constructor(private api: ModuleApi) {}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
if (!this.api.widgetLifecycle) {
|
||||
throw new Error(
|
||||
"Widget lifecycle API is not available. Update Element Web to a build that provides widget lifecycle module support.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
this.config = parseWidgetLifecycleConfig(this.api.config.get(CONFIG_KEY));
|
||||
} catch (error) {
|
||||
console.error("[WidgetLifecycle] Failed to init module", error);
|
||||
this.config = {};
|
||||
}
|
||||
|
||||
this.api.widgetLifecycle.registerPreloadApprover((widget) => this.preapprovePreload(widget));
|
||||
this.api.widgetLifecycle.registerIdentityApprover((widget) => this.preapproveIdentity(widget));
|
||||
this.api.widgetLifecycle.registerCapabilitiesApprover((widget, requested) =>
|
||||
this.preapproveCapabilities(widget, requested),
|
||||
);
|
||||
}
|
||||
|
||||
private preapprovePreload(widget: WidgetDescriptor): boolean {
|
||||
const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
|
||||
return configuration.preload_approved === true;
|
||||
}
|
||||
|
||||
private preapproveIdentity(widget: WidgetDescriptor): boolean {
|
||||
const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
|
||||
return configuration.identity_approved === true;
|
||||
}
|
||||
|
||||
private preapproveCapabilities(
|
||||
widget: WidgetDescriptor,
|
||||
requestedCapabilities: Set<string>,
|
||||
): Set<string> | undefined {
|
||||
const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
|
||||
const capabilitiesApproved = configuration.capabilities_approved;
|
||||
|
||||
if (!capabilitiesApproved) return undefined;
|
||||
|
||||
const approvedCapabilities = new Set<string>();
|
||||
for (const requestedCapability of requestedCapabilities) {
|
||||
if (capabilitiesApproved.some((capability) => matchPattern(requestedCapability, capability))) {
|
||||
approvedCapabilities.add(requestedCapability);
|
||||
}
|
||||
}
|
||||
|
||||
return approvedCapabilities.size > 0 ? approvedCapabilities : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { z } from "zod/mini";
|
||||
|
||||
z.config(z.locales.en());
|
||||
|
||||
/** The config.json key under which widget lifecycle module configuration is stored. */
|
||||
export const CONFIG_KEY = "io.element.element-web-modules.widget-lifecycle";
|
||||
|
||||
const WidgetConfigurationSchema = z.partial(
|
||||
z.looseObject({
|
||||
preload_approved: z.boolean(),
|
||||
identity_approved: z.boolean(),
|
||||
capabilities_approved: z.array(z.string().check(z.minLength(1))),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Per-widget approval settings: preload, identity, and capabilities. */
|
||||
export type WidgetConfiguration = z.infer<typeof WidgetConfigurationSchema>;
|
||||
|
||||
const ModuleConfigSchema = z.partial(
|
||||
z.looseObject({
|
||||
widget_permissions: z.record(z.string(), WidgetConfigurationSchema),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Map from URL patterns to their widget approval configuration. */
|
||||
export type WidgetLifecycleModuleConfig = Record<string, WidgetConfiguration>;
|
||||
|
||||
declare module "@element-hq/element-web-module-api" {
|
||||
export interface Config {
|
||||
[CONFIG_KEY]: z.input<typeof ModuleConfigSchema>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate the widget lifecycle module configuration.
|
||||
* Returns an empty config if the input is falsy; throws on schema violations.
|
||||
*/
|
||||
export const parseWidgetLifecycleConfig = (value: unknown): WidgetLifecycleModuleConfig => {
|
||||
if (!value) return {};
|
||||
|
||||
const result = ModuleConfigSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new Error(`Errors in the module configuration for "${CONFIG_KEY}": ${result.error.message}`);
|
||||
}
|
||||
|
||||
return result.data.widget_permissions ?? {};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { ModuleFactory } from "@element-hq/element-web-module-api";
|
||||
import WidgetLifecycleModule from "./WidgetLifecycleModule";
|
||||
|
||||
export default WidgetLifecycleModule satisfies ModuleFactory;
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { WidgetLifecycleModuleConfig, WidgetConfiguration } from "../config";
|
||||
import { matchPattern } from "./matchPattern";
|
||||
|
||||
/**
|
||||
* Returns the WidgetConfiguration for a widget.
|
||||
* If multiple WidgetConfigurations match, the most specific match wins per field.
|
||||
*/
|
||||
export function constructWidgetPermissions(
|
||||
config: WidgetLifecycleModuleConfig,
|
||||
widgetUrl: string,
|
||||
): WidgetConfiguration {
|
||||
const widgetPermissionsMatched = Object.keys(config).filter((pattern) => matchPattern(widgetUrl, pattern));
|
||||
|
||||
return widgetPermissionsMatched.sort(sortLongestMatchLast).reduce((prev, key) => ({ ...prev, ...config[key] }), {});
|
||||
}
|
||||
|
||||
/** Sort strings alphabetically so longer, more-specific patterns are applied last. */
|
||||
export function sortLongestMatchLast(a: string, b: string): number {
|
||||
return a.localeCompare(b, "en", { sensitivity: "base" });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if string matches pattern. Pattern can end with '*' to support prefix matching.
|
||||
*/
|
||||
export function matchPattern(value: string, pattern: string): boolean {
|
||||
return pattern.endsWith("*") ? value.startsWith(pattern.slice(0, pattern.length - 1)) : value === pattern;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import WidgetLifecycleModule, { type WidgetLifecycleApiAdapter } from "../src/WidgetLifecycleModule";
|
||||
import type {
|
||||
CapabilitiesApprover,
|
||||
IdentityApprover,
|
||||
PreloadApprover,
|
||||
WidgetDescriptor,
|
||||
} from "@element-hq/element-web-module-api";
|
||||
|
||||
const createApi = (config: unknown = {}) => {
|
||||
const handlers: {
|
||||
preload?: PreloadApprover;
|
||||
identity?: IdentityApprover;
|
||||
capabilities?: CapabilitiesApprover;
|
||||
} = {};
|
||||
|
||||
const widgetLifecycle: WidgetLifecycleApiAdapter = {
|
||||
registerPreloadApprover: (approver) => {
|
||||
handlers.preload = approver;
|
||||
},
|
||||
registerIdentityApprover: (approver) => {
|
||||
handlers.identity = approver;
|
||||
},
|
||||
registerCapabilitiesApprover: (approver) => {
|
||||
handlers.capabilities = approver;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
api: {
|
||||
config: {
|
||||
get: () => config,
|
||||
},
|
||||
widgetLifecycle,
|
||||
},
|
||||
handlers,
|
||||
};
|
||||
};
|
||||
|
||||
const widget: WidgetDescriptor = {
|
||||
id: "widget-id",
|
||||
templateUrl: "https://example.com/",
|
||||
creatorUserId: "@user-id",
|
||||
kind: "room",
|
||||
};
|
||||
|
||||
describe("WidgetLifecycleModule", () => {
|
||||
it("approves preload when configured", async () => {
|
||||
const { api, handlers } = createApi({
|
||||
widget_permissions: {
|
||||
"https://example.com/": { preload_approved: true },
|
||||
},
|
||||
});
|
||||
|
||||
const module = new WidgetLifecycleModule(api);
|
||||
await module.load();
|
||||
|
||||
expect(await handlers.preload?.(widget)).toBe(true);
|
||||
});
|
||||
|
||||
it("approves identity when configured", async () => {
|
||||
const { api, handlers } = createApi({
|
||||
widget_permissions: {
|
||||
"https://example.com/": { identity_approved: true },
|
||||
},
|
||||
});
|
||||
|
||||
const module = new WidgetLifecycleModule(api);
|
||||
await module.load();
|
||||
|
||||
expect(await handlers.identity?.(widget)).toBe(true);
|
||||
});
|
||||
|
||||
it("approves configured capabilities", async () => {
|
||||
const { api, handlers } = createApi({
|
||||
widget_permissions: {
|
||||
"https://example.com/": {
|
||||
capabilities_approved: ["org.matrix.msc2931.navigate"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const module = new WidgetLifecycleModule(api);
|
||||
await module.load();
|
||||
|
||||
const approved = await handlers.capabilities?.(
|
||||
widget,
|
||||
new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]),
|
||||
);
|
||||
|
||||
expect(approved).toEqual(new Set(["org.matrix.msc2931.navigate"]));
|
||||
});
|
||||
|
||||
it("returns no approvals when not configured", async () => {
|
||||
const { api, handlers } = createApi({});
|
||||
|
||||
const module = new WidgetLifecycleModule(api);
|
||||
await module.load();
|
||||
|
||||
expect(await handlers.preload?.(widget)).toBe(false);
|
||||
expect(await handlers.identity?.(widget)).toBe(false);
|
||||
expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns no approvals when config is missing", async () => {
|
||||
const { api, handlers } = createApi(undefined);
|
||||
|
||||
const module = new WidgetLifecycleModule(api);
|
||||
await module.load();
|
||||
|
||||
expect(await handlers.preload?.(widget)).toBe(false);
|
||||
expect(await handlers.identity?.(widget)).toBe(false);
|
||||
expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed on invalid config", async () => {
|
||||
const { api, handlers } = createApi({
|
||||
widget_permissions: {
|
||||
"https://example.com/": {
|
||||
preload_approved: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const module = new WidgetLifecycleModule(api);
|
||||
await module.load();
|
||||
|
||||
expect(await handlers.preload?.(widget)).toBe(false);
|
||||
expect(await handlers.identity?.(widget)).toBe(false);
|
||||
expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseWidgetLifecycleConfig } from "../src/config";
|
||||
|
||||
describe("parseWidgetLifecycleConfig", () => {
|
||||
it("accepts missing configuration", () => {
|
||||
expect(parseWidgetLifecycleConfig(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts empty configuration", () => {
|
||||
expect(parseWidgetLifecycleConfig({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts valid configuration", () => {
|
||||
expect(
|
||||
parseWidgetLifecycleConfig({
|
||||
widget_permissions: {
|
||||
"https://localhost": {
|
||||
preload_approved: true,
|
||||
identity_approved: false,
|
||||
capabilities_approved: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
"https://localhost": {
|
||||
preload_approved: true,
|
||||
identity_approved: false,
|
||||
capabilities_approved: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts additional properties", () => {
|
||||
expect(
|
||||
parseWidgetLifecycleConfig({
|
||||
widget_permissions: {
|
||||
"https://localhost": {
|
||||
preload_approved: true,
|
||||
identity_approved: false,
|
||||
capabilities_approved: ["capability"],
|
||||
additional: "tmp",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
"https://localhost": {
|
||||
preload_approved: true,
|
||||
identity_approved: false,
|
||||
capabilities_approved: ["capability"],
|
||||
additional: "tmp",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ preload_approved: null },
|
||||
{ preload_approved: 123 },
|
||||
{ identity_approved: null },
|
||||
{ identity_approved: 123 },
|
||||
{ capabilities_approved: null },
|
||||
{ capabilities_approved: 123 },
|
||||
{ capabilities_approved: [undefined] },
|
||||
{ capabilities_approved: [null] },
|
||||
{ capabilities_approved: [123] },
|
||||
{ capabilities_approved: [""] },
|
||||
])("rejects invalid widget configuration %j", (patch) => {
|
||||
expect(() =>
|
||||
parseWidgetLifecycleConfig({
|
||||
widget_permissions: {
|
||||
"https://localhost": {
|
||||
preload_approved: true,
|
||||
identity_approved: false,
|
||||
capabilities_approved: ["capability"],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { constructWidgetPermissions, sortLongestMatchLast } from "../src/utils/constructWidgetPermissions";
|
||||
|
||||
describe("constructWidgetPermissions", () => {
|
||||
it("finds exact match", () => {
|
||||
expect(constructWidgetPermissions({ "https://a.com/": { preload_approved: true } }, "https://a.com/")).toEqual({
|
||||
preload_approved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds prefix match", () => {
|
||||
expect(
|
||||
constructWidgetPermissions({ "https://a.com/*": { preload_approved: true } }, "https://a.com/some"),
|
||||
).toEqual({ preload_approved: true });
|
||||
});
|
||||
|
||||
it("merges multiple permissions", () => {
|
||||
expect(
|
||||
constructWidgetPermissions(
|
||||
{
|
||||
"https://b.com/path": {
|
||||
preload_approved: false,
|
||||
capabilities_approved: ["org.matrix.msc2762.timeline:*"],
|
||||
},
|
||||
"https://b.com/*": {
|
||||
preload_approved: true,
|
||||
identity_approved: true,
|
||||
capabilities_approved: ["org.matrix.msc2931.navigate"],
|
||||
},
|
||||
},
|
||||
"https://b.com/path",
|
||||
),
|
||||
).toEqual({
|
||||
preload_approved: false,
|
||||
identity_approved: true,
|
||||
capabilities_approved: ["org.matrix.msc2762.timeline:*"],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips unknown url", () => {
|
||||
expect(constructWidgetPermissions({ "https://a.com/": { preload_approved: true } }, "https://a.com/x")).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortLongestMatchLast", () => {
|
||||
it("sorts longest match last", () => {
|
||||
expect(
|
||||
[
|
||||
"org.matrix.msc2762.receive.state_event:*",
|
||||
"org.matrix.msc2762.receive.*",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom*",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom#state_key",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom#*",
|
||||
"*",
|
||||
].sort(sortLongestMatchLast),
|
||||
).toEqual([
|
||||
"*",
|
||||
"org.matrix.msc2762.receive.*",
|
||||
"org.matrix.msc2762.receive.state_event:*",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom*",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom#*",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom#state_key",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2023 Nordeck IT + Consulting GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { matchPattern } from "../src/utils/matchPattern";
|
||||
|
||||
describe("matchPattern", () => {
|
||||
it.each([
|
||||
"*",
|
||||
"org.matrix.msc2762.receive.*",
|
||||
"org.matrix.msc2762.receive.state_event:*",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom*",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom#*",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom#state_key",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom#state_key*",
|
||||
])("matches %s", (pattern) => {
|
||||
expect(matchPattern("org.matrix.msc2762.receive.state_event:m.custom#state_key", pattern)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"org.matrix.msc2762.receive.state_event:",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom",
|
||||
"org.matrix.msc2762.receive.state_event:m.custom#other_key",
|
||||
"org.matrix.msc2762.receive.*:m.custom#state_key",
|
||||
"org.matrix.msc2762.receive.room_event:m.custom",
|
||||
])("does not match %s", (pattern) => {
|
||||
expect(matchPattern("org.matrix.msc2762.receive.state_event:m.custom#state_key", pattern)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
name: "element-web-module-widget-lifecycle",
|
||||
fileName: "index",
|
||||
formats: ["es"],
|
||||
},
|
||||
outDir: "lib",
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
},
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
exclude: ["./e2e/**/*", "./node_modules/**/*"],
|
||||
reporters: ["default"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.ts"],
|
||||
reporter: [["lcov", { projectRoot: "../../../" }], "text"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
# Widget Toggles Module
|
||||
|
||||
Adds room header buttons for widgets in the room.
|
||||
|
||||
This module needs to be configured to control what widget types get buttons added for them.
|
||||
The following config snippet enables the module and configures it to add buttons for both
|
||||
custom and jitsi widgets:
|
||||
|
||||
```
|
||||
"modules": [
|
||||
"/modules/widget-toggles/lib/index.js"
|
||||
],
|
||||
"io.element.element-web-modules.widget-toggles": {
|
||||
"types": ["m.custom", "jitsi"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
<html>
|
||||
<body>
|
||||
<h1>This is the content of the widget</h1>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Page } from "@playwright/test";
|
||||
import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api";
|
||||
import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer";
|
||||
import { type Container } from "@element-hq/element-web-module-api";
|
||||
|
||||
import { test as base, expect } from "../../playwright/element-web-test.ts";
|
||||
|
||||
const test = base.extend<{
|
||||
// Resolver for when to respond to the navigation.json request
|
||||
navigationJsonResolver: PromiseWithResolvers<void>;
|
||||
}>({
|
||||
navigationJsonResolver: async ({}, use) => {
|
||||
await use(Promise.withResolvers<void>());
|
||||
},
|
||||
});
|
||||
|
||||
const TEST_WIDGET_NAME = "Name of the test widget";
|
||||
|
||||
async function makeRoomWithWidgetAndGoTo(
|
||||
homeserver: StartedHomeserverContainer,
|
||||
user: Credentials,
|
||||
page: Page,
|
||||
avatarUrl?: string,
|
||||
): Promise<string> {
|
||||
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
||||
"POST",
|
||||
"/v3/createRoom",
|
||||
user.accessToken,
|
||||
{
|
||||
name: "Come on in we've got widgets",
|
||||
},
|
||||
);
|
||||
await homeserver.csApi.request<{
|
||||
event_id: string;
|
||||
}>("PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, user.accessToken, {
|
||||
id: "1",
|
||||
creatorUserId: user.userId,
|
||||
type: "m.custom",
|
||||
name: TEST_WIDGET_NAME,
|
||||
url: `http://localhost:8080/widget.html`,
|
||||
avatar_url: avatarUrl,
|
||||
});
|
||||
|
||||
await page.goto(`/#/room/${roomId}`);
|
||||
|
||||
return roomId;
|
||||
}
|
||||
|
||||
async function moveWidgetToContainer(
|
||||
homeserver: StartedHomeserverContainer,
|
||||
user: Credentials,
|
||||
roomId: string,
|
||||
container: Container,
|
||||
): Promise<void> {
|
||||
await homeserver.csApi.request(
|
||||
"PUT",
|
||||
`/v3/user/${encodeURIComponent(user.userId)}/rooms/${encodeURIComponent(roomId)}/account_data/im.vector.web.settings`,
|
||||
user.accessToken,
|
||||
{
|
||||
"Widgets.layout": {
|
||||
widgets: {
|
||||
"1": { container },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("widget-toggles", () => {
|
||||
test.use({
|
||||
displayName: "Timmy",
|
||||
page: async ({ context, page, moduleDir }, use) => {
|
||||
await context.route("http://localhost:8080/widget.html*", async (route) => {
|
||||
await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" });
|
||||
});
|
||||
|
||||
await context.route("http://localhost:8080/wigeon.png", async (route) => {
|
||||
await route.fulfill({ path: `${moduleDir}/e2e/fixture/wigeon.png`, contentType: "image/png" });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
test("should error if config is missing", async ({ page }) => {
|
||||
await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
|
||||
await expect(page.getByText("Errors in module configuration")).toBeVisible();
|
||||
// We don't take a screenshot as we don't want to assert Element's styling, only our own
|
||||
});
|
||||
|
||||
test.describe("with correct config", () => {
|
||||
test.use({
|
||||
config: {
|
||||
"io.element.element-web-modules.widget-toggles": {
|
||||
types: ["m.custom"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test(
|
||||
"should render 'show' button for widget not in top",
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ homeserver, page, user }) => {
|
||||
await makeRoomWithWidgetAndGoTo(homeserver, user, page);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should render 'hide' button for widget in top",
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ homeserver, page, user }) => {
|
||||
const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page);
|
||||
|
||||
await moveWidgetToContainer(homeserver, user, roomId, "top");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test("should move widget to top when 'show' button is clicked", async ({ homeserver, page, user }) => {
|
||||
await makeRoomWithWidgetAndGoTo(homeserver, user, page);
|
||||
|
||||
await page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).click();
|
||||
await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible();
|
||||
|
||||
await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should move widget to left when 'hide' button is clicked", async ({ homeserver, page, user }) => {
|
||||
const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page);
|
||||
await moveWidgetToContainer(homeserver, user, roomId, "top");
|
||||
|
||||
await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME }).click();
|
||||
|
||||
await expect(page.locator('iframe[title="Name of the test widget"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("uses widget icon for button image if present", async ({ homeserver, page, user }) => {
|
||||
await makeRoomWithWidgetAndGoTo(homeserver, user, page, "mxc://fakehomeserver/fake_content_id");
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).getByRole("img"),
|
||||
).toHaveAttribute("src", /\/_matrix\/media\/v3\/download\/fakehomeserver\/fake_content_id$/);
|
||||
});
|
||||
|
||||
test(
|
||||
"uses built in icon for widgets with no avatar",
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ homeserver, page, user }) => {
|
||||
await makeRoomWithWidgetAndGoTo(homeserver, user, page);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toMatchScreenshot(
|
||||
"widget-toggle-button-default-icon.png",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@element-hq/element-web-module-widget-toggle",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"lint": "pnpm lint:types && pnpm lint:js",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:js": "eslint --max-warnings 0 src -c ../.eslintrc.cjs",
|
||||
"test": "vitest run --coverage",
|
||||
"test:playwright": "playwright test -c ../playwright.config.ts",
|
||||
"test:playwright:open": "yarn test:playwright -c ../playwright.config.ts --ui",
|
||||
"test:playwright:screenshots": "playwright-screenshots yarn test:playwright --update-snapshots --grep @screenshot"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
|
||||
"@element-hq/element-web-module-api": "workspace:*",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/browser-playwright": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"react": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"rollup-plugin-external-globals": "^0.13.0",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-node-polyfills": "catalog:",
|
||||
"vite-plugin-svgr": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-web": "^9.0.0",
|
||||
"matrix-widget-api": "^1.17.0",
|
||||
"styled-components": "^6.3.11",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { z, type input } from "zod/mini";
|
||||
|
||||
z.config(z.locales.en());
|
||||
|
||||
export const WidgetTogglesConfig = z.object({
|
||||
/**
|
||||
* The widget types to show a toggle for.
|
||||
*/
|
||||
types: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type WidgetTogglesConfig = z.infer<typeof WidgetTogglesConfig>;
|
||||
|
||||
export const CONFIG_KEY = "io.element.element-web-modules.widget-toggles";
|
||||
|
||||
declare module "@element-hq/element-web-module-api" {
|
||||
export interface Config {
|
||||
[CONFIG_KEY]: input<WidgetTogglesConfig>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type JSX } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
|
||||
import { CONFIG_KEY, WidgetTogglesConfig } from "./config";
|
||||
import { WidgetToggle } from "./toggle";
|
||||
|
||||
class WidgetToggleModule implements Module {
|
||||
public static readonly moduleApiVersion = "^1.12.0";
|
||||
private config?: WidgetTogglesConfig;
|
||||
|
||||
public constructor(private api: Api) {}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
try {
|
||||
this.config = WidgetTogglesConfig.parse(this.api.config.get(CONFIG_KEY));
|
||||
} catch (e) {
|
||||
console.error("Failed to init module", e);
|
||||
throw new Error(`Errors in module configuration for widget toggles module`);
|
||||
}
|
||||
|
||||
this.api.extras.addRoomHeaderButtonCallback((roomId: string) => {
|
||||
const widgets = this.api.widget.getWidgetsInRoom(roomId);
|
||||
const toggleElements: JSX.Element[] = [];
|
||||
|
||||
for (const widget of widgets) {
|
||||
if (this.config?.types.includes(widget.type)) {
|
||||
toggleElements.push(
|
||||
<WidgetToggle
|
||||
app={widget}
|
||||
roomId={roomId}
|
||||
widgetApi={this.api.widget}
|
||||
i18nApi={this.api.i18n}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (toggleElements.length === 0) return undefined;
|
||||
|
||||
// XXX: We shouldn't have to add another TooltipProvider here, it should
|
||||
// be using the one in MatrixChat in Element Web, but thanks to the fact
|
||||
// that contexts are "magically" identified by class, it doesn't pick up the
|
||||
// context because we use a different copy of compound-web. We'll probably
|
||||
// need to fix this at some point, possibly by moving compound's tooltip stuff
|
||||
// out to its own mini-module that can be provided at runtime by Element Web,
|
||||
// unless React make contexts more sensible.
|
||||
// Annoyingly this does actually cause the tooltips to behave a bit weirdly:
|
||||
// there's a delay before the tooltip appears when yo move between these buttons
|
||||
// and the rest of the header buttons, whereas moving between the other header
|
||||
// buttons, the tooltips appear straight away once one has appeared.
|
||||
return <TooltipProvider>{toggleElements}</TooltipProvider>;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default WidgetToggleModule satisfies ModuleFactory;
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2024 Nordeck IT + Consulting GmbH
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* 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 { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api";
|
||||
import { IconButton, Tooltip } from "@vector-im/compound-web";
|
||||
import { type IWidget } from "matrix-widget-api";
|
||||
import React, { type JSX } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = { $isInContainer: boolean };
|
||||
|
||||
const Img = styled.img<Props>`
|
||||
border-color: ${(): string => "var(--cpd-color-text-action-accent)"};
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
border-width: ${({ $isInContainer }): string => ($isInContainer ? "2px" : "0px")};
|
||||
box-sizing: border-box;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
const Svg = styled.svg<Props>`
|
||||
height: 24px;
|
||||
fill: ${({ $isInContainer }): string => ($isInContainer ? "var(--cpd-color-text-action-accent)" : "currentColor")};
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
function avatarUrl(app: IWidget, widgetApi: WidgetApi): string | null {
|
||||
if (app.type.match(/jitsi/i)) {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxyZWN0IHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcng9IjQiIGZpbGw9IiM1QUJGRjIiLz4KICAgIDxwYXRoIGQ9Ik0zIDcuODc1QzMgNi44Mzk0NyAzLjgzOTQ3IDYgNC44NzUgNkgxMS4xODc1QzEyLjIyMyA2IDEzLjA2MjUgNi44Mzk0NyAxMy4wNjI1IDcuODc1VjEyLjg3NUMxMy4wNjI1IDEzLjkxMDUgMTIuMjIzIDE0Ljc1IDExLjE4NzUgMTQuNzVINC44NzVDMy44Mzk0NyAxNC43NSAzIDEzLjkxMDUgMyAxMi44NzVWNy44NzVaIiBmaWxsPSJ3aGl0ZSIvPgogICAgPHBhdGggZD0iTTE0LjM3NSA4LjQ0NjQ0TDE2LjEyMDggNy4xMTAzOUMxNi40ODA2IDYuODM1MDIgMTcgNy4wOTE1OCAxNyA3LjU0NDY4VjEzLjAzOTZDMTcgMTMuNTE5OSAxNi40MjUxIDEzLjc2NjkgMTYuMDc2NyAxMy40MzYzTDE0LjM3NSAxMS44MjE0VjguNDQ2NDRaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K";
|
||||
}
|
||||
|
||||
return widgetApi.getAppAvatarUrl(app);
|
||||
}
|
||||
|
||||
function isInContainer(app: IWidget, widgetApi: WidgetApi, roomId: string): boolean {
|
||||
return widgetApi.isAppInContainer(app, "center", roomId) || widgetApi.isAppInContainer(app, "top", roomId);
|
||||
}
|
||||
|
||||
type WidgetToggleProps = {
|
||||
app: IWidget;
|
||||
roomId: string;
|
||||
widgetApi: WidgetApi;
|
||||
i18nApi: I18nApi;
|
||||
};
|
||||
|
||||
export function WidgetToggle({ app, roomId, widgetApi, i18nApi }: WidgetToggleProps): JSX.Element {
|
||||
const appAvatarUrl = avatarUrl(app, widgetApi);
|
||||
const appNameOrType = app.name ?? app.type;
|
||||
const inContainer = isInContainer(app, widgetApi, roomId);
|
||||
|
||||
const label = i18nApi.translate(inContainer ? "Hide %(name)s" : "Show %(name)s", { name: appNameOrType });
|
||||
|
||||
const onClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (inContainer) {
|
||||
widgetApi.moveAppToContainer(app, "right", roomId);
|
||||
} else {
|
||||
widgetApi.moveAppToContainer(app, "top", roomId);
|
||||
}
|
||||
},
|
||||
[app, inContainer, roomId, widgetApi],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} key={app.id}>
|
||||
<IconButton aria-label={label} onClick={onClick}>
|
||||
{appAvatarUrl ? (
|
||||
<Img $isInContainer={inContainer} alt={appNameOrType} src={appAvatarUrl} />
|
||||
) : (
|
||||
<Svg $isInContainer={inContainer} role="presentation">
|
||||
<path d="M16 2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 2.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3ZM16 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Zm.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3ZM4 14h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2Zm.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3Z M8 2H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Z" />
|
||||
</Svg>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { WidgetTogglesConfig } from "../src/config";
|
||||
|
||||
describe("WidgetTogglesConfig", () => {
|
||||
test("parses a valid config with an array of widget types", () => {
|
||||
const result = WidgetTogglesConfig.safeParse({ types: ["m.video", "m.audio"] });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.types).toEqual(["m.video", "m.audio"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("parses a valid config with an empty types array", () => {
|
||||
const result = WidgetTogglesConfig.safeParse({ types: [] });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.types).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a config missing the types field", () => {
|
||||
const result = WidgetTogglesConfig.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a config where types is not an array", () => {
|
||||
const result = WidgetTogglesConfig.safeParse({ types: "m.video" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a config where types contains non-string values", () => {
|
||||
const result = WidgetTogglesConfig.safeParse({ types: [1, 2, 3] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type Api } from "@element-hq/element-web-module-api";
|
||||
import { type IWidget } from "matrix-widget-api";
|
||||
|
||||
import WidgetToggleModule from "../src/index";
|
||||
import { CONFIG_KEY, WidgetTogglesConfig } from "../src/config";
|
||||
import { mockWidget, mockWidgetApi } from "./mocks";
|
||||
|
||||
const makeApi = (widgets: IWidget[] = []): Api => {
|
||||
const addRoomHeaderButtonCallback = vi.fn();
|
||||
return {
|
||||
config: {
|
||||
get: vi.fn().mockReturnValue({ types: ["m.custom"] }),
|
||||
},
|
||||
extras: {
|
||||
addRoomHeaderButtonCallback,
|
||||
},
|
||||
widget: mockWidgetApi({
|
||||
getWidgetsInRoom: vi.fn().mockReturnValue(widgets),
|
||||
}),
|
||||
i18n: {
|
||||
translate: vi.fn().mockImplementation((key: string) => key),
|
||||
},
|
||||
} as unknown as Api;
|
||||
};
|
||||
|
||||
vi.mock("../src/config", async () => {
|
||||
return {
|
||||
CONFIG_KEY: "fake_config_key",
|
||||
WidgetTogglesConfig: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("WidgetToggleModule", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("load", () => {
|
||||
test("reads config using CONFIG_KEY", async () => {
|
||||
const api = makeApi();
|
||||
|
||||
const module = new WidgetToggleModule(api);
|
||||
await module.load();
|
||||
|
||||
expect(api.config.get).toHaveBeenCalledWith(CONFIG_KEY);
|
||||
});
|
||||
|
||||
test("parses config with WidgetTogglesConfig.parse", async () => {
|
||||
const api = makeApi();
|
||||
const rawConfig = { types: ["m.custom"] };
|
||||
(api.config.get as ReturnType<typeof vi.fn>).mockReturnValue(rawConfig);
|
||||
|
||||
const module = new WidgetToggleModule(api);
|
||||
await module.load();
|
||||
|
||||
expect(WidgetTogglesConfig.parse).toHaveBeenCalledWith(rawConfig);
|
||||
});
|
||||
|
||||
test("registers a room header button callback", async () => {
|
||||
const api = makeApi();
|
||||
(WidgetTogglesConfig.parse as ReturnType<typeof vi.fn>).mockReturnValue({ types: ["m.custom"] });
|
||||
|
||||
const module = new WidgetToggleModule(api);
|
||||
await module.load();
|
||||
|
||||
expect(api.extras.addRoomHeaderButtonCallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("throws error when config parsing fails", async () => {
|
||||
const api = makeApi();
|
||||
(WidgetTogglesConfig.parse as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("Invalid config");
|
||||
});
|
||||
|
||||
const module = new WidgetToggleModule(api);
|
||||
await expect(module.load()).rejects.toThrow("Errors in module configuration for widget toggles module");
|
||||
});
|
||||
});
|
||||
|
||||
describe("room header button callback", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
const getCallback = async (api: Api): Promise<(roomId: string) => React.JSX.Element | undefined> => {
|
||||
(WidgetTogglesConfig.parse as ReturnType<typeof vi.fn>).mockReturnValue({ types: ["m.custom"] });
|
||||
const module = new WidgetToggleModule(api);
|
||||
await module.load();
|
||||
return (api.extras.addRoomHeaderButtonCallback as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
};
|
||||
|
||||
test("returns undefined when there are no widgets in the room", async () => {
|
||||
const api = makeApi([]);
|
||||
const callback = await getCallback(api);
|
||||
|
||||
const result = callback(roomId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when no widgets match the configured types", async () => {
|
||||
const api = makeApi([mockWidget({ type: "m.other" })]);
|
||||
const callback = await getCallback(api);
|
||||
|
||||
const result = callback(roomId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("renders WidgetToggle for each matching widget", async () => {
|
||||
const api = makeApi([
|
||||
mockWidget({ id: "w1", type: "m.custom", name: "Widget One" }),
|
||||
mockWidget({ id: "w2", type: "m.custom", name: "Widget Two" }),
|
||||
]);
|
||||
(api.i18n.translate as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(key: string, vars?: Record<string, string>) => {
|
||||
let result = key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
result = result.replace(`%(${k})s`, v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
const callback = await getCallback(api);
|
||||
|
||||
const result = callback(roomId);
|
||||
expect(result).toBeDefined();
|
||||
render(result!);
|
||||
|
||||
expect(screen.getAllByRole("button").length).toBe(2);
|
||||
});
|
||||
|
||||
test("does not render WidgetToggle for non-matching widget types", async () => {
|
||||
const api = makeApi([
|
||||
mockWidget({ id: "w1", type: "m.custom", name: "Widget One" }),
|
||||
mockWidget({ id: "w2", type: "m.other", name: "Widget Other" }),
|
||||
]);
|
||||
(api.i18n.translate as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(key: string, vars?: Record<string, string>) => {
|
||||
let result = key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
result = result.replace(`%(${k})s`, v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
const callback = await getCallback(api);
|
||||
|
||||
const result = callback(roomId);
|
||||
expect(result).toBeDefined();
|
||||
render(result!);
|
||||
|
||||
expect(screen.getAllByRole("button").length).toBe(1);
|
||||
});
|
||||
|
||||
test("calls getWidgetsInRoom with correct roomId", async () => {
|
||||
const api = makeApi([]);
|
||||
const callback = await getCallback(api);
|
||||
|
||||
callback(roomId);
|
||||
expect(api.widget.getWidgetsInRoom).toHaveBeenCalledWith(roomId);
|
||||
});
|
||||
});
|
||||
});
|
||||