diff --git a/modules/restricted-guests/README.md b/modules/restricted-guests/README.md new file mode 100644 index 0000000000..9904fa8698 --- /dev/null +++ b/modules/restricted-guests/README.md @@ -0,0 +1,17 @@ +# Restricted Guests Module + +A pair of modules to allow guests to register with Element using the Module API. + +Users get a link to an ask to join room, enter their name, and can participate in the room without any further registration. + +These guest users + +- have a real user account on the Homeserver. +- get a username with the (configurable) pattern @guest-. +- have a display name that always includes the (configurable) suffix (Guest). +- are restricted in what they can do (can't create rooms or participate in direct messages on the homeserver). +- are only temporary and will be deactivated after a lifetime of (configurable) 24 hours. + +This was initially created to allow non-organisation members to join NeoDateFix meeting rooms, even if they don't have a user account in the private and potentially non-federated homeserver. + +See further documentation in the Synapse and Element Web module directories. diff --git a/modules/restricted-guests/element-web/README.md b/modules/restricted-guests/element-web/README.md new file mode 100644 index 0000000000..75a29f2eed --- /dev/null +++ b/modules/restricted-guests/element-web/README.md @@ -0,0 +1,11 @@ +# @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. | diff --git a/modules/restricted-guests/element-web/package.json b/modules/restricted-guests/element-web/package.json new file mode 100644 index 0000000000..6d01895cb3 --- /dev/null +++ b/modules/restricted-guests/element-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "@element-hq/element-web-module-restricted-guests", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "scripts": { + "prepare": "vite build", + "lint:types": "tsc --noEmit", + "lint:codestyle": "echo 'handled by lint:eslint'", + "test": "echo no tests yet" + }, + "devDependencies": { + "@element-hq/element-web-module-api": "^1.0.0", + "@types/node": "^22.10.7", + "@types/react": "^19", + "@vitejs/plugin-react": "^4.3.4", + "react": "^19", + "rollup-plugin-external-globals": "^0.13.0", + "typescript": "^5.7.3", + "vite": "^6.1.6", + "vite-plugin-node-polyfills": "^0.23.0" + }, + "dependencies": { + "@vector-im/compound-design-tokens": "^4.0.3", + "@vector-im/compound-web": "^7.11.0", + "styled-components": "^6.1.18", + "zod": "^3.24.2" + } +} diff --git a/modules/restricted-guests/element-web/src/RegisterDialog.tsx b/modules/restricted-guests/element-web/src/RegisterDialog.tsx new file mode 100644 index 0000000000..47bc0f7e18 --- /dev/null +++ b/modules/restricted-guests/element-web/src/RegisterDialog.tsx @@ -0,0 +1,89 @@ +/* +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 { FC, useState, type JSX, 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 { ModuleConfig } from "./config.ts"; + +export interface RegisterDialogProps extends DialogProps { + api: Api; + config: ModuleConfig; +} + +const enum State { + Idle, + Busy, + Error, +} + +const RegisterDialog: FC = ({ api, config, onCancel, onSubmit }) => { + const [username, setUsername] = useState(""); + const [state, setState] = useState(State.Idle); + + async function trySubmit(ev: FormEvent): Promise { + 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 = {api.i18n.translate("register_dialog_error")}; + } else if (state === State.Busy) { + message = {api.i18n.translate("register_dialog_busy")}; + } + + const disabled = state !== State.Idle; + + return ( + + + {api.i18n.translate("register_dialog_register_username_label")} + { + setUsername(event.currentTarget.value); + }} + placeholder={api.i18n.translate("register_dialog_field_label")} + /> + {message} + + + + {api.i18n.translate("register_dialog_existing_account")} + + + + {api.i18n.translate("register_dialog_continue_label")} + + + ); +}; + +export default RegisterDialog; diff --git a/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx b/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx new file mode 100644 index 0000000000..e67c11ce1a --- /dev/null +++ b/modules/restricted-guests/element-web/src/RoomPreviewBar.tsx @@ -0,0 +1,66 @@ +/* +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 { 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 { ModuleConfig } from "./config.ts"; +import RegisterDialog from "./RegisterDialog.tsx"; + +export interface RoomPreviewBarProps { + api: Api; + config: ModuleConfig; + children: JSX.Element; + roomId?: string; + roomAlias?: string; + promptAskToJoin?: boolean; +} + +const Container = styled.aside` + margin: auto; +`; + +const RoomPreviewBar: FC = ({ 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 => { + const { finished } = api.openDialog( + { + title: api.i18n.translate("register_dialog_title"), + }, + RegisterDialog, + { + api, + config, + }, + ); + + const { model: accountAuthInfo, ok } = await finished; + + if (ok && accountAuthInfo) { + await api.overwriteAccountAuth(accountAuthInfo); + await api.navigation.toMatrixToLink(`https://matrix.to/#/${roomId ?? roomAlias}`, true); + } + }; + + return ( + +
{api.i18n.translate("join_message")}
+
+ +
+
+ ); +}; + +export default RoomPreviewBar; diff --git a/modules/restricted-guests/element-web/src/config.ts b/modules/restricted-guests/element-web/src/config.ts new file mode 100644 index 0000000000..c340068766 --- /dev/null +++ b/modules/restricted-guests/element-web/src/config.ts @@ -0,0 +1,45 @@ +/* +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, ZodSchema, ZodTypeDef } from "zod"; + +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.string().url(), + + /** + * The username prefix that identifies guest users. + * @defaultValue `@guest-` + */ + guest_user_prefix: z + .string() + .regex(/@[a-zA-Z-_1-9]+/) + .default("@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.boolean().default(false), +}); + +export type ModuleConfig = z.infer; + +export type ConfigSchema = ZodSchema, ZodTypeDef, z.input>; + +export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests"; + +declare module "@element-hq/element-web-module-api" { + export interface Config { + [CONFIG_KEY]: ConfigSchema["_input"]; + } +} diff --git a/modules/restricted-guests/element-web/src/index.tsx b/modules/restricted-guests/element-web/src/index.tsx new file mode 100644 index 0000000000..564945ffe3 --- /dev/null +++ b/modules/restricted-guests/element-web/src/index.tsx @@ -0,0 +1,65 @@ +/* +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 { 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"; + +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 { + 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}"`); + } + + this.api.customComponents.registerRoomPreviewBar((props, OriginalComponent) => ( + + + + )); + + // TODO replace this with a more generic API + this.api._registerLegacyComponentVisibilityCustomisations(this); + } + + /** + * 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 => { + if (!this.config || !this.api.profile.value.userId?.startsWith(this.config.guest_user_prefix)) { + return true; + } + + return GUEST_INVISIBLE_COMPONENTS.includes(component); + }; +} + +export default RestrictedGuestsModule satisfies ModuleFactory; diff --git a/modules/restricted-guests/element-web/src/translations.json b/modules/restricted-guests/element-web/src/translations.json new file mode 100644 index 0000000000..bca1dc4a76 --- /dev/null +++ b/modules/restricted-guests/element-web/src/translations.json @@ -0,0 +1,38 @@ +{ + "register_dialog_register_username_label": { + "en": "Username", + "de": "Benutzername" + }, + "register_dialog_title": { + "en": "Request room access", + "de": "Raumbeitritt anfragen" + }, + "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", + "de": "Verbinden" + } +} diff --git a/modules/restricted-guests/element-web/tests/restricted-guests.spec.ts b/modules/restricted-guests/element-web/tests/restricted-guests.spec.ts new file mode 100644 index 0000000000..f8f180a0cb --- /dev/null +++ b/modules/restricted-guests/element-web/tests/restricted-guests.spec.ts @@ -0,0 +1,133 @@ +/* +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 } from "@element-hq/element-web-playwright-common/lib/testcontainers"; +import { Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api.ts"; + +import { test as base, expect } from "../../../../playwright/element-web-test.ts"; +import { RestrictedGuestsSynapseContainer } from "./services.ts"; + +const test = base.extend< + { + testRoomId: string; + }, + { + guestHomeserver: StartedSynapseContainer; + bot: Credentials; + } +>({ + testRoomId: [ + async ({ homeserver, bot }, use) => { + const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( + "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" }, + }, + ], + }, + ); + await use(roomId); + }, + { scope: "test" }, + ], + bot: [ + async ({ homeserver }, use) => { + const bot = await homeserver.registerUser("bot", "pAs5w0rD!", "Bot"); + await use(bot); + }, + { scope: "worker" }, + ], + guestHomeserver: [ + async ({ logger, synapseConfig, network }, use) => { + const container = await new RestrictedGuestsSynapseContainer() + .withConfig(synapseConfig) + .withConfig({ server_name: "guest-homeserver" }) + .withNetwork(network) + .withNetworkAliases("guest-homeserver") + .withLogConsumer(logger.getConsumer("guest_homeserver")) + .start(); + + await use(container); + await container.stop(); + }, + { scope: "worker" }, + ], +}); + +test.use({ + displayName: "Tommy", + synapseConfig: { + allow_guest_access: true, + }, + labsFlags: ["feature_ask_to_join"], +}); + +test.describe("Restricted Guests", () => { + test.use({ + page: async ({ page, homeserver, guestHomeserver }, use) => { + 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(); + }); + + test.describe("with config", () => { + test.beforeEach(({ config, guestHomeserver }) => { + config["io.element.element-web-modules.restricted-guests"] = { + guest_user_homeserver_url: guestHomeserver.baseUrl, + }; + }); + + 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, testRoomId }) => { + // Go to a room we are not a member of + await page.goto(`/#/room/${testRoomId}`); + + const button = page.getByRole("button", { name: "Join", 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(); + }, + ); + }); +}); diff --git a/modules/restricted-guests/element-web/tests/services.ts b/modules/restricted-guests/element-web/tests/services.ts new file mode 100644 index 0000000000..6b56108b06 --- /dev/null +++ b/modules/restricted-guests/element-web/tests/services.ts @@ -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 { + 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 { + public override async start(): Promise { + 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: {}, + }); + + return super.start(); + } +} diff --git a/modules/restricted-guests/element-web/tsconfig.json b/modules/restricted-guests/element-web/tsconfig.json new file mode 100644 index 0000000000..fbda6380e7 --- /dev/null +++ b/modules/restricted-guests/element-web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "lib", + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/modules/restricted-guests/element-web/vite.config.ts b/modules/restricted-guests/element-web/vite.config.ts new file mode 100644 index 0000000000..3663c873e1 --- /dev/null +++ b/modules/restricted-guests/element-web/vite.config.ts @@ -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 { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import externalGlobals from "rollup-plugin-external-globals"; + +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, + rollupOptions: { + external: ["react"], + }, + }, + plugins: [ + 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" } }, + }, +});